diff --git a/README.md b/README.md index e4d71ee..9e68aad 100644 --- a/README.md +++ b/README.md @@ -124,11 +124,12 @@ If you want to debugger the project, please run `yarn webpack-dev` first, and pr * [x] extension icon * [x] bundle by webpack/rollup * [x] enhance 'bucketFolder' -* [ ] delete image when hover GFM(github flavored markdown) +* [x] delete image when hover GFM(github flavored markdown) * [ ] inquire before upload to check folder * [ ] decoupling logic by tapable * [ ] upload embed svg as *.svg from clipboard * [ ] image compress (by imagemin/ aliyun OSS can realize it by adding '?x-oss-process=' after url) +* [ ] x-oss-process & vscode.CodeActionProvider * [ ] unit test * [ ] editor/title button to upload image * [ ] sidebar extension (e.g. show recent uploaded image) diff --git a/src/commands/hoverDelete.ts b/src/commands/hoverDelete.ts new file mode 100644 index 0000000..8d67183 --- /dev/null +++ b/src/commands/hoverDelete.ts @@ -0,0 +1,35 @@ +import deleteUri from '@/utils/uploader/deleteUri' +import { getActiveMd } from '@/utils/index' +import vscode from 'vscode' + +interface PositionToJSON { + readonly line: number + readonly character: number +} + +type RangeToJSON = Array + +export default async function hoverDelete( + uri: string, + fileName: string, + range: RangeToJSON +): Promise { + await deleteUri(vscode.Uri.parse(uri)) + const [start, end] = range + const vsRange = new vscode.Range( + start.line, + start.character, + end.line, + end.character + ) + deleteGFM(fileName, vsRange) +} + +function deleteGFM(fileName: string, range: vscode.Range): void { + const activeTextMd = getActiveMd() + if (!activeTextMd) return + if (activeTextMd.document.fileName !== fileName) return + activeTextMd.edit((editBuilder) => { + editBuilder.delete(range) + }) +} diff --git a/src/commands/uploadFromExplorer.ts b/src/commands/uploadFromExplorer.ts index ad5f30f..c2b86ea 100644 --- a/src/commands/uploadFromExplorer.ts +++ b/src/commands/uploadFromExplorer.ts @@ -1,10 +1,11 @@ import vscode from 'vscode' import uploadUris from '@/utils/uploader/uploadUris' +import { SUPPORT_EXT } from '@/utils/constant' export default async function uploadImageFromExplorer(): Promise { const result = await vscode.window.showOpenDialog({ filters: { - Images: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'tiff', 'ico', 'svg'] + Images: SUPPORT_EXT.slice() }, canSelectMany: true }) diff --git a/src/extension.ts b/src/extension.ts index e253a7a..8c8de21 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,6 +2,8 @@ import vscode from 'vscode' import uploadFromClipboard from './commands/uploadFromClipboard' import uploadFromExplorer from './commands/uploadFromExplorer' import uploadFromExplorerContext from './commands/uploadFromExplorerContext' +import hoverDelete from './commands/hoverDelete' +import hover from './language/hover' import Logger from './utils/log' // this method is called when your extension is activated @@ -22,7 +24,9 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands.registerCommand( 'elan.uploadFromExplorerContext', uploadFromExplorerContext - ) + ), + vscode.commands.registerCommand('elan.delete', hoverDelete), + vscode.languages.registerHoverProvider('markdown', hover) ] context.subscriptions.push(...disposable) } diff --git a/src/language/hover.ts b/src/language/hover.ts new file mode 100644 index 0000000..37b28e5 --- /dev/null +++ b/src/language/hover.ts @@ -0,0 +1,56 @@ +import vscode, { Hover } from 'vscode' +import { isAliyunOssUri } from '@/utils/index' +import { MARKDOWN_PATH_REG } from '@/utils/constant' + +function getCommandUriString( + text: string, + command: string, + ...args: unknown[] +): string { + const uri = vscode.Uri.parse( + `command:${command}` + + (args.length ? `?${encodeURIComponent(JSON.stringify(args))}` : '') + ) + return `[${text}](${uri})` +} + +class HoverProvider implements vscode.HoverProvider { + provideHover( + document: vscode.TextDocument, + position: vscode.Position + ): vscode.ProviderResult { + const keyRange = this.getKeyRange(document, position) + if (!keyRange) return + + const uriMatch = MARKDOWN_PATH_REG.exec(document.getText(keyRange)) + if (!uriMatch) return + + const uri = uriMatch[1] + + if (!isAliyunOssUri(uri)) return + + const delCommandUri = getCommandUriString( + 'Delete image', + 'elan.delete', + uri, + document.fileName, + keyRange + ) + const contents = new vscode.MarkdownString(delCommandUri) + contents.isTrusted = true + return new Hover(contents, keyRange) + } + getKeyRange( + document: vscode.TextDocument, + position: vscode.Position + ): vscode.Range | undefined { + const keyRange = document.getWordRangeAtPosition( + position, + MARKDOWN_PATH_REG + ) + + return keyRange + } +} + +export default new HoverProvider() diff --git a/src/utils/constant.ts b/src/utils/constant.ts new file mode 100644 index 0000000..afd7db2 --- /dev/null +++ b/src/utils/constant.ts @@ -0,0 +1,12 @@ +export const SUPPORT_EXT: ReadonlyArray = [ + 'png', + 'jpg', + 'jpeg', + 'webp', + 'gif', + 'bmp', + 'tiff', + 'ico', + 'svg' +] +export const MARKDOWN_PATH_REG = /!\[.*?\]\((.+?)\)/g diff --git a/src/utils/index.ts b/src/utils/index.ts index 9f19df5..baf90c6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,8 @@ import vscode from 'vscode' import crypto from 'crypto' import fs from 'fs' import Logger from './log' +import OSS from 'ali-oss' +import { SUPPORT_EXT } from '@/utils/constant' export function isSubDirectory(parent: string, dir: string): boolean { const relative = path.relative(parent, dir) @@ -41,3 +43,72 @@ export function getActiveMd(): vscode.TextEditor | undefined { return return activeTextEditor } + +export function isAliyunOssUri(uri: string): boolean { + try { + const vsUri = vscode.Uri.parse(uri) + + if (!['http', 'https'].includes(vsUri.scheme)) return false + + const { bucket, region } = getOssConfiguration() + const [_bucket, _region] = vsUri.authority.split('.') + if (bucket !== _bucket) return false + if (region !== _region) return false + + const ext = path.extname(vsUri.path).substr(1) + if (!SUPPORT_EXT.includes(ext)) return false + + return true + } catch { + return false + } +} + +export function removeLeadingSlash(p: string): string { + return p.replace(/^\/+/, '') +} + +export function getOssConfiguration(): OSS.Options { + const config = vscode.workspace.getConfiguration('elan') + const aliyunConfig = config.get('aliyun', { + accessKeyId: '', + accessKeySecret: '' + }) + return { + secure: true, // ensure protocol of callback url is https + accessKeyId: aliyunConfig.accessKeyId.trim(), + accessKeySecret: aliyunConfig.accessKeySecret.trim(), + bucket: aliyunConfig.bucket?.trim(), + region: aliyunConfig.region?.trim() + } +} + +interface Progress { + progress: vscode.Progress<{ message?: string; increment?: number }> + progressResolve: (value?: unknown) => void + progressReject: (value?: unknown) => void +} + +export function getProgress(title = 'Uploading image'): Progress { + let progressResolve, progressReject, progress + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title + }, + (p) => { + return new Promise((resolve, reject) => { + progressResolve = resolve + progressReject = reject + progress = p + }) + } + ) + if (!progress || !progressResolve || !progressReject) + throw new Error('Failed to init vscode progress') + return { + progress, + progressResolve, + progressReject + } +} diff --git a/src/utils/uploader/deleteUri.ts b/src/utils/uploader/deleteUri.ts new file mode 100644 index 0000000..8f56fc8 --- /dev/null +++ b/src/utils/uploader/deleteUri.ts @@ -0,0 +1,29 @@ +import Uploader from './index' +import { getProgress, removeLeadingSlash } from '@/utils' +import vscode from 'vscode' +import Logger from '@/utils/log' + +export default async function deleteUri(uri: vscode.Uri): Promise { + const uploader = Uploader.get() + // init OSS instance failed + if (!uploader) return + + const name = removeLeadingSlash(uri.path) + const { progress, progressResolve } = getProgress(`Deleting image`) + try { + await uploader.delete(name) + progress.report({ + message: `Finish.`, + increment: 100 + }) + setTimeout(() => { + progressResolve() + }, 1000) + } catch (err) { + progressResolve() + Logger.showErrorMessage( + `Failed to delete image. See output channel for more details` + ) + Logger.log(`Failed: ${name}.` + ` Reason: ${err.message}`) + } +} diff --git a/src/utils/uploader/index.ts b/src/utils/uploader/index.ts index 1506124..5ed55ee 100644 --- a/src/utils/uploader/index.ts +++ b/src/utils/uploader/index.ts @@ -1,20 +1,10 @@ import vscode from 'vscode' import OSS from 'ali-oss' import Logger from '@/utils/log' +import { getOssConfiguration } from '@/utils/index' -function initOSSOptions(): OSS.Options { - const config = vscode.workspace.getConfiguration('elan') - const aliyunConfig = config.get('aliyun', { - accessKeyId: '', - accessKeySecret: '' - }) - return { - secure: true, // ensure protocol of callback url is https - accessKeyId: aliyunConfig.accessKeyId.trim(), - accessKeySecret: aliyunConfig.accessKeySecret.trim(), - bucket: aliyunConfig.bucket?.trim(), - region: aliyunConfig.region?.trim() - } +interface DeleteResponse { + res: OSS.NormalSuccessResponse } export default class Uploader { @@ -22,7 +12,7 @@ export default class Uploader { private client: OSS public expired: boolean constructor() { - this.client = new OSS(initOSSOptions()) + this.client = new OSS(getOssConfiguration()) this.expired = false // instance is expired if configuration update @@ -52,4 +42,11 @@ export default class Uploader { ): Promise { return this.client.put(name, fsPath, options) } + async delete( + name: string, + options?: OSS.RequestOptions + ): Promise { + // FIXME: @types/ali-oss bug, I will create pr + return this.client.delete(name, options) as any + } } diff --git a/src/utils/uploader/uploadUris.ts b/src/utils/uploader/uploadUris.ts index c9cef0f..feac137 100644 --- a/src/utils/uploader/uploadUris.ts +++ b/src/utils/uploader/uploadUris.ts @@ -2,7 +2,7 @@ import vscode from 'vscode' import path from 'path' import { TemplateStore } from './templateStore' import Logger from '@/utils/log' -import { getActiveMd } from '@/utils/index' +import { getActiveMd, getProgress } from '@/utils/index' import Uploader from './index' declare global { @@ -23,42 +23,12 @@ interface WrapError extends Error { imageName: string } -interface UploadingProgress { - progress: vscode.Progress<{ message?: string; increment?: number }> - progressResolve: (value?: unknown) => void - progressReject: (value?: unknown) => void -} - -function getUploadingProgress(title = 'Uploading image'): UploadingProgress { - let progressResolve, progressReject, progress - vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title - }, - (p) => { - return new Promise((resolve, reject) => { - progressResolve = resolve - progressReject = reject - progress = p - }) - } - ) - if (!progress || !progressResolve || !progressReject) - throw new Error('Failed to init vscode progress') - return { - progress, - progressResolve, - progressReject - } -} - export default async function uploadUris(uris: vscode.Uri[]): Promise { const uploader = Uploader.get() // init OSS instance failed if (!uploader) return - const { progress, progressResolve } = getUploadingProgress( + const { progress, progressResolve } = getProgress( `Uploading ${uris.length} image(s)` ) const clipboard: string[] = []