eae5d8c807
These conflicts will be resolved in the following commits. We do it this way so that PR review is possible.
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||
*--------------------------------------------------------------------------------------------*/
|
||
|
||
// Based on @sergeche's work on the emmet plugin for atom
|
||
|
||
import { TextEditor, Position, window, TextEdit } from 'vscode';
|
||
import * as path from 'path';
|
||
import { getImageSize } from './imageSizeHelper';
|
||
import { getFlatNode, iterateCSSToken, getCssPropertyFromRule, isStyleSheet, validate, offsetRangeToVsRange } from './util';
|
||
import { HtmlNode, CssToken, HtmlToken, Attribute, Property } from 'EmmetFlatNode';
|
||
import { locateFile } from './locateFile';
|
||
import parseStylesheet from '@emmetio/css-parser';
|
||
import { getRootNode } from './parseDocument';
|
||
|
||
/**
|
||
* Updates size of context image in given editor
|
||
*/
|
||
export function updateImageSize(): Promise<boolean> | undefined {
|
||
if (!validate() || !window.activeTextEditor) {
|
||
return;
|
||
}
|
||
const editor = window.activeTextEditor;
|
||
|
||
const allUpdatesPromise = editor.selections.reverse().map(selection => {
|
||
const position = selection.isReversed ? selection.active : selection.anchor;
|
||
if (!isStyleSheet(editor.document.languageId)) {
|
||
return updateImageSizeHTML(editor, position);
|
||
} else {
|
||
return updateImageSizeCSSFile(editor, position);
|
||
}
|
||
});
|
||
|
||
return Promise.all(allUpdatesPromise).then((updates) => {
|
||
return editor.edit(builder => {
|
||
updates.forEach(update => {
|
||
update.forEach((textEdit: TextEdit) => {
|
||
builder.replace(textEdit.range, textEdit.newText);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Updates image size of context tag of HTML model
|
||
*/
|
||
function updateImageSizeHTML(editor: TextEditor, position: Position): Promise<TextEdit[]> {
|
||
const imageNode = getImageHTMLNode(editor, position);
|
||
|
||
const src = imageNode && getImageSrcHTML(imageNode);
|
||
|
||
if (!src) {
|
||
return updateImageSizeStyleTag(editor, position);
|
||
}
|
||
|
||
return locateFile(path.dirname(editor.document.fileName), src)
|
||
.then(getImageSize)
|
||
.then((size: any) => {
|
||
// since this action is asynchronous, we have to ensure that editor wasn’t
|
||
// changed and user didn’t moved caret outside <img> node
|
||
const img = getImageHTMLNode(editor, position);
|
||
if (img && getImageSrcHTML(img) === src) {
|
||
return updateHTMLTag(editor, img, size.width, size.height);
|
||
}
|
||
return [];
|
||
})
|
||
.catch(err => { console.warn('Error while updating image size:', err); return []; });
|
||
}
|
||
|
||
function updateImageSizeStyleTag(editor: TextEditor, position: Position): Promise<TextEdit[]> {
|
||
const getPropertyInsiderStyleTag = (editor: TextEditor): Property | null => {
|
||
const document = editor.document;
|
||
const rootNode = getRootNode(document, true);
|
||
const offset = document.offsetAt(position);
|
||
const currentNode = <HtmlNode>getFlatNode(rootNode, offset, true);
|
||
if (currentNode && currentNode.name === 'style'
|
||
&& currentNode.open && currentNode.close
|
||
&& currentNode.open.end < offset
|
||
&& currentNode.close.start > offset) {
|
||
const buffer = ' '.repeat(currentNode.open.end) +
|
||
document.getText().substring(currentNode.open.end, currentNode.close.start);
|
||
const innerRootNode = parseStylesheet(buffer);
|
||
const innerNode = getFlatNode(innerRootNode, offset, true);
|
||
return (innerNode && innerNode.type === 'property') ? <Property>innerNode : null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return updateImageSizeCSS(editor, position, getPropertyInsiderStyleTag);
|
||
}
|
||
|
||
function updateImageSizeCSSFile(editor: TextEditor, position: Position): Promise<TextEdit[]> {
|
||
return updateImageSizeCSS(editor, position, getImageCSSNode);
|
||
}
|
||
|
||
/**
|
||
* Updates image size of context rule of stylesheet model
|
||
*/
|
||
function updateImageSizeCSS(editor: TextEditor, position: Position, fetchNode: (editor: TextEditor, position: Position) => Property | null): Promise<TextEdit[]> {
|
||
const node = fetchNode(editor, position);
|
||
const src = node && getImageSrcCSS(editor, node, position);
|
||
|
||
if (!src) {
|
||
return Promise.reject(new Error('No valid image source'));
|
||
}
|
||
|
||
return locateFile(path.dirname(editor.document.fileName), src)
|
||
.then(getImageSize)
|
||
.then((size: any): TextEdit[] => {
|
||
// since this action is asynchronous, we have to ensure that editor wasn’t
|
||
// changed and user didn’t moved caret outside <img> node
|
||
const prop = fetchNode(editor, position);
|
||
if (prop && getImageSrcCSS(editor, prop, position) === src) {
|
||
return updateCSSNode(editor, prop, size.width, size.height);
|
||
}
|
||
return [];
|
||
})
|
||
.catch(err => { console.warn('Error while updating image size:', err); return []; });
|
||
}
|
||
|
||
/**
|
||
* Returns <img> node under caret in given editor or `null` if such node cannot
|
||
* be found
|
||
*/
|
||
function getImageHTMLNode(editor: TextEditor, position: Position): HtmlNode | null {
|
||
const document = editor.document;
|
||
const rootNode = getRootNode(document, true);
|
||
const offset = document.offsetAt(position);
|
||
const node = <HtmlNode>getFlatNode(rootNode, offset, true);
|
||
|
||
return node && node.name.toLowerCase() === 'img' ? node : null;
|
||
}
|
||
|
||
/**
|
||
* Returns css property under caret in given editor or `null` if such node cannot
|
||
* be found
|
||
*/
|
||
function getImageCSSNode(editor: TextEditor, position: Position): Property | null {
|
||
const document = editor.document;
|
||
const rootNode = getRootNode(document, true);
|
||
const offset = document.offsetAt(position);
|
||
const node = getFlatNode(rootNode, offset, true);
|
||
return node && node.type === 'property' ? <Property>node : null;
|
||
}
|
||
|
||
/**
|
||
* Returns image source from given <img> node
|
||
*/
|
||
function getImageSrcHTML(node: HtmlNode): string | undefined {
|
||
const srcAttr = getAttribute(node, 'src');
|
||
if (!srcAttr) {
|
||
return;
|
||
}
|
||
|
||
return (<HtmlToken>srcAttr.value).value;
|
||
}
|
||
|
||
/**
|
||
* Returns image source from given `url()` token
|
||
*/
|
||
function getImageSrcCSS(editor: TextEditor, node: Property | undefined, position: Position): string | undefined {
|
||
if (!node) {
|
||
return;
|
||
}
|
||
const urlToken = findUrlToken(editor, node, position);
|
||
if (!urlToken) {
|
||
return;
|
||
}
|
||
|
||
// A stylesheet token may contain either quoted ('string') or unquoted URL
|
||
let urlValue = urlToken.item(0);
|
||
if (urlValue && urlValue.type === 'string') {
|
||
urlValue = urlValue.item(0);
|
||
}
|
||
|
||
return urlValue && urlValue.valueOf();
|
||
}
|
||
|
||
/**
|
||
* Updates size of given HTML node
|
||
*/
|
||
function updateHTMLTag(editor: TextEditor, node: HtmlNode, width: number, height: number): TextEdit[] {
|
||
const document = editor.document;
|
||
const srcAttr = getAttribute(node, 'src');
|
||
if (!srcAttr) {
|
||
return [];
|
||
}
|
||
|
||
const widthAttr = getAttribute(node, 'width');
|
||
const heightAttr = getAttribute(node, 'height');
|
||
const quote = getAttributeQuote(editor, srcAttr);
|
||
const endOfAttributes = node.attributes[node.attributes.length - 1].end;
|
||
|
||
let edits: TextEdit[] = [];
|
||
let textToAdd = '';
|
||
|
||
if (!widthAttr) {
|
||
textToAdd += ` width=${quote}${width}${quote}`;
|
||
} else {
|
||
edits.push(new TextEdit(offsetRangeToVsRange(document, widthAttr.value.start, widthAttr.value.end), String(width)));
|
||
}
|
||
if (!heightAttr) {
|
||
textToAdd += ` height=${quote}${height}${quote}`;
|
||
} else {
|
||
edits.push(new TextEdit(offsetRangeToVsRange(document, heightAttr.value.start, heightAttr.value.end), String(height)));
|
||
}
|
||
if (textToAdd) {
|
||
edits.push(new TextEdit(offsetRangeToVsRange(document, endOfAttributes, endOfAttributes), textToAdd));
|
||
}
|
||
|
||
return edits;
|
||
}
|
||
|
||
/**
|
||
* Updates size of given CSS rule
|
||
*/
|
||
function updateCSSNode(editor: TextEditor, srcProp: Property, width: number, height: number): TextEdit[] {
|
||
const document = editor.document;
|
||
const rule = srcProp.parent;
|
||
const widthProp = getCssPropertyFromRule(rule, 'width');
|
||
const heightProp = getCssPropertyFromRule(rule, 'height');
|
||
|
||
// Detect formatting
|
||
const separator = srcProp.separator || ': ';
|
||
const before = getPropertyDelimitor(editor, srcProp);
|
||
|
||
let edits: TextEdit[] = [];
|
||
if (!srcProp.terminatorToken) {
|
||
edits.push(new TextEdit(offsetRangeToVsRange(document, srcProp.end, srcProp.end), ';'));
|
||
}
|
||
|
||
let textToAdd = '';
|
||
if (!widthProp) {
|
||
textToAdd += `${before}width${separator}${width}px;`;
|
||
} else {
|
||
edits.push(new TextEdit(offsetRangeToVsRange(document, widthProp.valueToken.start, widthProp.valueToken.end), `${width}px`));
|
||
}
|
||
if (!heightProp) {
|
||
textToAdd += `${before}height${separator}${height}px;`;
|
||
} else {
|
||
edits.push(new TextEdit(offsetRangeToVsRange(document, heightProp.valueToken.start, heightProp.valueToken.end), `${height}px`));
|
||
}
|
||
if (textToAdd) {
|
||
edits.push(new TextEdit(offsetRangeToVsRange(document, srcProp.end, srcProp.end), textToAdd));
|
||
}
|
||
|
||
return edits;
|
||
}
|
||
|
||
/**
|
||
* Returns attribute object with `attrName` name from given HTML node
|
||
*/
|
||
function getAttribute(node: HtmlNode, attrName: string): Attribute | undefined {
|
||
attrName = attrName.toLowerCase();
|
||
return node && node.attributes.find(attr => attr.name.toString().toLowerCase() === attrName);
|
||
}
|
||
|
||
/**
|
||
* Returns quote character, used for value of given attribute. May return empty
|
||
* string if attribute wasn’t quoted
|
||
|
||
*/
|
||
function getAttributeQuote(editor: TextEditor, attr: Attribute): string {
|
||
const begin = attr.value ? attr.value.end : attr.end;
|
||
const end = attr.end;
|
||
return begin === end ? '' : editor.document.getText().substring(begin, end);
|
||
}
|
||
|
||
/**
|
||
* Finds 'url' token for given `pos` point in given CSS property `node`
|
||
*/
|
||
function findUrlToken(editor: TextEditor, node: Property, pos: Position): CssToken | undefined {
|
||
const offset = editor.document.offsetAt(pos);
|
||
for (let i = 0, il = (node as any).parsedValue.length, url; i < il; i++) {
|
||
iterateCSSToken((node as any).parsedValue[i], (token: CssToken) => {
|
||
if (token.type === 'url' && token.start <= offset && token.end >= offset) {
|
||
url = token;
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
if (url) {
|
||
return url;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
/**
|
||
* Returns a string that is used to delimit properties in current node’s rule
|
||
*/
|
||
function getPropertyDelimitor(editor: TextEditor, node: Property): string {
|
||
let anchor;
|
||
if (anchor = (node.previousSibling || node.parent.contentStartToken)) {
|
||
return editor.document.getText().substring(anchor.end, node.start);
|
||
} else if (anchor = (node.nextSibling || node.parent.contentEndToken)) {
|
||
return editor.document.getText().substring(node.end, anchor.start);
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|