Skip to content

Commit

Permalink
Merge pull request #177 from mjbvz/hover
Browse files Browse the repository at this point in the history
Add image/video path hover support
  • Loading branch information
mjbvz committed Apr 4, 2024
2 parents f6bf065 + 6299218 commit 7b20fce
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.5.0-alpha.5 — Unreleased
- Add links to open file in path completions.
- Add image preview for image files in path completions.
- Allow hovering over image/video paths to see preview of image or video.

## 0.5.0-alpha.4 — April 4, 2024
- Change update links on paste to generate minimal edit.
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createWorkspaceLinkCache, MdLinkProvider, ResolvedDocumentLinkTarget }
import { MdDocumentSymbolProvider } from './languageFeatures/documentSymbols';
import { FileRename, MdFileRenameProvider } from './languageFeatures/fileRename';
import { MdFoldingProvider } from './languageFeatures/folding';
import { MdHoverProvider } from './languageFeatures/hover';
import { MdOrganizeLinkDefinitionProvider } from './languageFeatures/organizeLinkDefs';
import { MdPathCompletionProvider, PathCompletionOptions } from './languageFeatures/pathCompletions';
import { MdReferencesProvider } from './languageFeatures/references';
Expand Down Expand Up @@ -197,6 +198,11 @@ export interface IMdLanguageService {
*/
getUpdatePastedLinksEdit(document: ITextDocument, paste: readonly lsp.TextEdit[], rawCopyMetadata: string, token: lsp.CancellationToken): Promise<lsp.TextEdit[] | undefined>;

/**
* Get the hover information for a position in the document.
*/
getHover(document: ITextDocument, pos: lsp.Position, token: lsp.CancellationToken): Promise<lsp.Hover | undefined>;

/**
* Compute diagnostics for a given file.
*
Expand Down Expand Up @@ -265,6 +271,7 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL
const organizeLinkDefinitions = new MdOrganizeLinkDefinitionProvider(linkProvider);
const documentHighlightProvider = new MdDocumentHighlightProvider(config, tocProvider, linkProvider);
const rewritePastedLinksProvider = new MdUpdatePastedLinksProvider(linkProvider);
const hoverProvider = new MdHoverProvider(linkProvider);

const extractCodeActionProvider = new MdExtractLinkDefinitionCodeActionProvider(linkProvider);
const removeLinkDefinitionActionProvider = new MdRemoveLinkDefinitionCodeActionProvider();
Expand Down Expand Up @@ -305,6 +312,7 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL
},
prepareUpdatePastedLinks: rewritePastedLinksProvider.prepareDocumentPaste.bind(rewritePastedLinksProvider),
getUpdatePastedLinksEdit: rewritePastedLinksProvider.provideDocumentPasteEdits.bind(rewritePastedLinksProvider),
getHover: hoverProvider.provideHover.bind(hoverProvider),
computeDiagnostics: async (doc: ITextDocument, options: DiagnosticOptions, token: lsp.CancellationToken): Promise<lsp.Diagnostic[]> => {
return (await diagnosticsComputer.compute(doc, options, token))?.diagnostics;
},
Expand Down
63 changes: 63 additions & 0 deletions src/languageFeatures/hover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as lsp from 'vscode-languageserver-protocol';
import { rangeContains } from '../types/range';
import { ITextDocument } from '../types/textDocument';
import * as mdBuilder from '../util/mdBuilder';
import { getMediaPreviewType, MediaType } from '../util/media';
import { HrefKind, MdLink, MdLinkProvider } from './documentLinks';

export class MdHoverProvider {

readonly #linkProvider: MdLinkProvider;

constructor(linkProvider: MdLinkProvider) {
this.#linkProvider = linkProvider;
}

public async provideHover(document: ITextDocument, pos: lsp.Position, token: lsp.CancellationToken): Promise<lsp.Hover | undefined> {
const links = await this.#linkProvider.getLinks(document);
if (token.isCancellationRequested) {
return;
}

const link = links.links.find(link => rangeContains(link.source.hrefRange, pos));
if (!link || link.href.kind === HrefKind.Reference) {
return;
}

const contents = this.#getHoverContents(link);
return contents && {
contents,
range: link.source.hrefRange
};
}

#getHoverContents(link: MdLink): lsp.MarkupContent | undefined {
if (link.href.kind === HrefKind.Reference) {
return undefined;
}

const uri = link.href.kind === HrefKind.External ? link.href.uri : link.href.path;
const mediaType = getMediaPreviewType(uri);
const maxWidth = 300;
switch (mediaType) {
case MediaType.Image: {
return {
kind: 'markdown',
value: mdBuilder.imageLink(uri, '', maxWidth),
};
}
case MediaType.Video: {
return {
kind: 'markdown',
value: mdBuilder.video(uri, maxWidth),
};
}
}
return undefined;
}
}
4 changes: 2 additions & 2 deletions src/languageFeatures/pathCompletions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,13 +498,13 @@ export class MdPathCompletionProvider {
#getPathDocumentation(uri: URI, stat: FileStat): lsp.MarkupContent {
let documentation = stat.isDirectory
? mdBuilder.inlineCode(uri.path + '/') // TODO: support links to folders too
: mdBuilder.codeLink(uri.path, uri.toString());
: mdBuilder.codeLink(uri.path, uri);

if (!stat.isDirectory) {
switch (getMediaPreviewType(uri)) {
case MediaType.Image: {
const maxImageWidth = 200;
documentation += `\n\n${mdBuilder.image(uri.toString() + `|width=${maxImageWidth}`, '')}`;
documentation += `\n\n${mdBuilder.imageLink(uri, 'Linked image', maxImageWidth)}`;
break;
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/test/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { githubSlugifier } from '../slugify';

export function createNewMarkdownEngine(): IMdParser {
const md = MarkdownIt({ html: true, });

// Allow file links
const validateLink = md.validateLink.bind(md);
md.validateLink = (link: string) => {
return validateLink(link) || link.startsWith('file://');
};

return {
slugifier: githubSlugifier,
async tokenize(document) {
Expand Down
120 changes: 120 additions & 0 deletions src/test/hover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import * as lsp from 'vscode-languageserver-protocol';
import { getLsConfiguration } from '../config';
import { MdLinkProvider } from '../languageFeatures/documentLinks';
import { MdHoverProvider } from '../languageFeatures/hover';
import { MdTableOfContentsProvider } from '../tableOfContents';
import { InMemoryDocument } from '../types/inMemoryDocument';
import { noopToken } from '../util/cancellation';
import { IWorkspace } from '../workspace';
import { createNewMarkdownEngine } from './engine';
import { InMemoryWorkspace } from './inMemoryWorkspace';
import { nulLogger } from './nulLogging';
import { assertRangeEqual, DisposableStore, joinLines, withStore, workspacePath } from './util';
import Token = require('markdown-it/lib/token');
import { URI } from 'vscode-uri';


function getHover(store: DisposableStore, doc: InMemoryDocument, pos: lsp.Position, workspace: IWorkspace) {
const engine = createNewMarkdownEngine();
const tocProvider = store.add(new MdTableOfContentsProvider(engine, workspace, nulLogger));
const linkProvider = store.add(new MdLinkProvider(getLsConfiguration({}), engine, workspace, tocProvider, nulLogger));
const provider = new MdHoverProvider(linkProvider);
return provider.provideHover(doc, pos, noopToken);
}

async function flatTokenizeContents(contents: lsp.MarkupContent) {
const engine = createNewMarkdownEngine();
const tokens = await engine.tokenize(new InMemoryDocument(workspacePath('fake'), contents.value));

function flatten(tokens: readonly Token[]): Token[] {
const out: Token[] = [];
for (const token of tokens) {
out.push(token);
if (token.children) {
out.push(...flatten(flatten(token.children)));
}
}
return out;
}
return flatten(tokens as Token[]);
}

async function findMdImageSrc(hover: lsp.Hover): Promise<URI | undefined> {
assert.ok(lsp.MarkupContent.is(hover.contents));
const tokens = await flatTokenizeContents(hover.contents);
const img = tokens.find(t => t.type === 'image')!;
const src = img!.attrs?.find(x => x[0] === 'src')?.[1];
if (!src) {
return;
}
const parsed = URI.parse(src);

// Remove `|width=...` from path
return parsed.with({
path: parsed.path.split('|')[0]
});
}

suite('Hover', () => {
test('Should return nothing if not on path', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`![img](./cat.png "title")`,
));
const workspace = store.add(new InMemoryWorkspace([doc]));

assert.ok(!await getHover(store, doc, { line: 0, character: 0 }, workspace));
assert.ok(!await getHover(store, doc, { line: 0, character: 3 }, workspace));
assert.ok(!await getHover(store, doc, { line: 0, character: 19 }, workspace));
}));

test('Should return image hover on MD image path', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`![img](./cat.png)`,
));
const workspace = store.add(new InMemoryWorkspace([doc]));

const hover = await getHover(store, doc, { line: 0, character: 10 }, workspace);
assert.ok(hover);

const src = await findMdImageSrc(hover);
assert.strictEqual(src?.toString(), workspacePath('cat.png').toString());

assertRangeEqual(lsp.Range.create(0, 7, 0, 16), hover.range!);
}));

test('Should handle MD images with spaces', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`![img](<./s p a c e.png>)`,
));
const workspace = store.add(new InMemoryWorkspace([doc]));

const hover = await getHover(store, doc, { line: 0, character: 10 }, workspace);
assert.ok(hover);

const src = await findMdImageSrc(hover);
assert.strictEqual(src?.toString(), workspacePath('s p a c e.png').toString());

assertRangeEqual(lsp.Range.create(0, 8, 0, 23), hover.range!);
}));

test('Should provide hover on <img> src', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`<img src="cat.png">`,
));
const workspace = store.add(new InMemoryWorkspace([doc]));

const hover = await getHover(store, doc, { line: 0, character: 12 }, workspace);
assert.ok(hover);

const src = await findMdImageSrc(hover);
assert.strictEqual(src?.toString(), workspacePath('cat.png').toString());

assertRangeEqual(lsp.Range.create(0, 10, 0, 17), hover.range!);
}));
});
31 changes: 24 additions & 7 deletions src/util/mdBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { URI } from 'vscode-uri';
import { escapeForAngleBracketLink, needsAngleBracketLink } from './mdLinks';

export function inlineCode(text: string): string {
Expand All @@ -12,17 +13,33 @@ export function inlineCode(text: string): string {
return `${backticks}${text}${backticks}`;
}

export function link(text: string, path: string): string {
text = escapeMarkdownSyntaxTokens(text.replace(/\n/, ''));
return `[${text}](${bracketPathIfNeeded(path)})`;
export function link(text: string, uri: URI): string {
const path = uri.toString();
return `[${escapeMarkdownSyntaxTokens(text.replace(/\n/, ''))}](${bracketPathIfNeeded(path)})`;
}

export function codeLink(text: string, path: string): string {
export function codeLink(text: string, uri: URI): string {
const path = uri.toString();
return `[${inlineCode(text)}](${bracketPathIfNeeded(path)})`;
}

export function image(path: string, alt: string): string {
return `![${alt}](${bracketPathIfNeeded(path)})`;
export function image(uri: URI, alt: string, width?: number): string {
const path = uri.toString();
return `![${alt}](${bracketPathIfNeeded(path + (width ? `|width=${width}` : ''))})`;
}

export function imageLink(uri: URI, alt: string, width?: number): string {
const path = uri.toString();
return `[${image(uri, alt, width)}](${bracketPathIfNeeded(path)})`;
}

export function video(uri: URI, width?: number): string {
const path = uri.toString();
return `<video width="${width ?? ''}" src="${escapeHtmlAttribute(path)}" autoplay loop controls muted></video>`;
}

function escapeHtmlAttribute(value: string): string {
return value.replace(/"/g, '&quot;');
}

function bracketPathIfNeeded(path: string) {
Expand All @@ -34,4 +51,4 @@ function bracketPathIfNeeded(path: string) {
*/
function escapeMarkdownSyntaxTokens(text: string): string {
return text.replace(/[\\`*_{}[\]()#+\-!~]/g, '\\$&'); // CodeQL [SM02383] Backslash is escaped in the character class
}
}
8 changes: 6 additions & 2 deletions src/util/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { URI, Utils } from 'vscode-uri';

export enum MediaType {
Image,
Video
}

/**
* List of common file extensions that can be previewed.
*/
const previewableMediaFileExtension = new Map<string, MediaType>([
// Images
// Image
['.bmp', MediaType.Image],
['.gif', MediaType.Image],
['.jpg', MediaType.Image],
Expand All @@ -23,7 +24,10 @@ const previewableMediaFileExtension = new Map<string, MediaType>([
['.webp', MediaType.Image],
['.ico', MediaType.Image],
['.tiff', MediaType.Image],
['.tif', MediaType.Image]
['.tif', MediaType.Image],

// Video
['.mp4', MediaType.Video],
]);

export function getMediaPreviewType(uri: URI): MediaType | undefined {
Expand Down

0 comments on commit 7b20fce

Please sign in to comment.