mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-28 13:26:52 +08:00
Remove image preview in favor of core image previewer (#341900)
* Agent Host changes for lramos15/agents/image-preview-context-menu-fix * mediaPreview: address review feedback for core media previews Encode copied images in a worker and pass PNG sources through as is, handle the browser's native copy event, discard stale carousel image loads, refresh previews on uncorrelated file changes from extension file systems, keep the keyboard focus ring, and show status bar info for the focused side of media diffs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * mediaPreview: address second round of review feedback Open the image context menu from the keyboard on the viewer's focus target, copy the image that is displayed rather than re-reading the file, show Git LFS and load errors within the preview so the other side of a diff stays visible, and make the zoom actions and status bar follow the focused side of image diffs. Tests now await signals or use faked timers instead of polling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
9b25398fe7
commit
e7d9908a81
75 files changed
+3304
-2745
No files matched your search
@@ -70,9 +70,6 @@ extensions/html/build/update-grammar.mjs
|
||||
extensions/json/build/update-grammars.js
|
||||
extensions/latex/build/update-grammars.js
|
||||
extensions/less/build/update-grammar.js
|
||||
extensions/media-preview/media/audioPreview.js
|
||||
extensions/media-preview/media/imagePreview.js
|
||||
extensions/media-preview/media/videoPreview.js
|
||||
extensions/microsoft-authentication/packageMocks/dpapi/dpapi.js
|
||||
extensions/microsoft-authentication/packageMocks/keytar/index.js
|
||||
extensions/objective-c/build/update-grammars.js
|
||||
|
||||
@@ -73,7 +73,6 @@ const compilations = [
|
||||
'extensions/json-language-features/server/tsconfig.json',
|
||||
'extensions/markdown-language-features/tsconfig.json',
|
||||
'extensions/markdown-math/tsconfig.json',
|
||||
'extensions/media-preview/tsconfig.json',
|
||||
'extensions/merge-conflict/tsconfig.json',
|
||||
'extensions/mermaid-markdown-features/tsconfig.json',
|
||||
'extensions/terminal-suggest/tsconfig.json',
|
||||
|
||||
@@ -146,6 +146,10 @@
|
||||
"name": "vs/workbench/contrib/markers",
|
||||
"project": "vscode-workbench"
|
||||
},
|
||||
{
|
||||
"name": "vs/workbench/contrib/mediaPreview",
|
||||
"project": "vscode-workbench"
|
||||
},
|
||||
{
|
||||
"name": "vs/workbench/contrib/mergeEditor",
|
||||
"project": "vscode-workbench"
|
||||
|
||||
@@ -90,6 +90,7 @@ const workerEntryPoints = [
|
||||
'vs/workbench/services/languageDetection/browser/languageDetectionWebWorkerMain',
|
||||
'vs/workbench/services/search/worker/localFileSearchMain',
|
||||
'vs/workbench/contrib/output/common/outputLinkComputerMain',
|
||||
'vs/workbench/contrib/mediaPreview/browser/imageEncoderWorkerMain',
|
||||
'vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain',
|
||||
];
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export const dirs = [
|
||||
'extensions/json-language-features/server',
|
||||
'extensions/markdown-language-features',
|
||||
'extensions/markdown-math',
|
||||
'extensions/media-preview',
|
||||
'extensions/merge-conflict',
|
||||
'extensions/mermaid-markdown-features',
|
||||
'extensions/microsoft-authentication',
|
||||
|
||||
+1
-5
@@ -2641,7 +2641,6 @@ export default defineConfig(
|
||||
'extensions/markdown-language-features/preview-src/**/*.ts',
|
||||
'extensions/mermaid-markdown-features/preview-src/chat/**/*.ts',
|
||||
'extensions/mermaid-markdown-features/src/**/*.ts',
|
||||
'extensions/media-preview/src/**/*.ts',
|
||||
'extensions/simple-browser/**/*.ts',
|
||||
'extensions/typescript-language-features/**/*.ts',
|
||||
],
|
||||
@@ -2654,10 +2653,7 @@ export default defineConfig(
|
||||
'extensions/markdown-language-features/notebook/tsconfig.json',
|
||||
'extensions/markdown-language-features/preview-src/tsconfig.json',
|
||||
|
||||
// Media preview
|
||||
'extensions/media-preview/tsconfig.json',
|
||||
|
||||
// Media preview
|
||||
// Simple browser
|
||||
'extensions/simple-browser/tsconfig.json',
|
||||
'extensions/simple-browser/preview-src/tsconfig.json',
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
legacy-peer-deps="true"
|
||||
timeout=180000
|
||||
min-release-age=7
|
||||
@@ -1,10 +0,0 @@
|
||||
test/**
|
||||
src/**
|
||||
tsconfig*.json
|
||||
**/*.tsbuildinfo
|
||||
esbuild*
|
||||
out/test/**
|
||||
out/**
|
||||
cgmanifest.json
|
||||
package-lock.json
|
||||
preview-src/**
|
||||
@@ -1,28 +0,0 @@
|
||||
# Media Preview
|
||||
|
||||
**Notice:** This extension is bundled with Visual Studio Code. It can be disabled but not uninstalled.
|
||||
|
||||
## Features
|
||||
|
||||
This extension provides basic preview for images, audio and video files.
|
||||
|
||||
### Supported image file extensions
|
||||
|
||||
- `.jpg`, `.jpe`, `.jpeg`
|
||||
- `.png`
|
||||
- `.bmp`
|
||||
- `.gif`
|
||||
- `.ico`
|
||||
- `.webp`
|
||||
- `.avif`
|
||||
|
||||
### Supported audio formats
|
||||
|
||||
- `.mp3`
|
||||
- `.wav`
|
||||
- `.ogg`, `.oga`
|
||||
|
||||
### Supported video formats
|
||||
|
||||
- `.mp4` (does not support `aac` audio tracks)
|
||||
- `.webm` (vp8 only)
|
||||
@@ -1,21 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as path from 'node:path';
|
||||
import { run } from '../esbuild-extension-common.mts';
|
||||
|
||||
const srcDir = path.join(import.meta.dirname, 'src');
|
||||
const outDir = path.join(import.meta.dirname, 'dist', 'browser');
|
||||
|
||||
run({
|
||||
platform: 'browser',
|
||||
entryPoints: {
|
||||
'extension': path.join(srcDir, 'extension.ts'),
|
||||
},
|
||||
srcDir,
|
||||
outdir: outDir,
|
||||
additionalOptions: {
|
||||
tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'),
|
||||
},
|
||||
}, process.argv);
|
||||
@@ -1,18 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as path from 'node:path';
|
||||
import { run } from '../esbuild-extension-common.mts';
|
||||
|
||||
const srcDir = path.join(import.meta.dirname, 'src');
|
||||
const outDir = path.join(import.meta.dirname, 'dist');
|
||||
|
||||
run({
|
||||
platform: 'node',
|
||||
entryPoints: {
|
||||
'extension': path.join(srcDir, 'extension.ts'),
|
||||
},
|
||||
srcDir,
|
||||
outdir: outDir,
|
||||
}, process.argv);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,64 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 5px 10px;
|
||||
box-sizing: border-box;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.audio-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container.loading,
|
||||
.container.error,
|
||||
.container.git-lfs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-image: url('./loading.svg');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.loading-indicator,
|
||||
.loading-error,
|
||||
.git-lfs-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading .loading-indicator,
|
||||
.error .loading-error,
|
||||
.git-lfs .git-lfs-info {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading-error,
|
||||
.git-lfs-info {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
.vscode-dark .loading-indicator {
|
||||
background-image: url('./loading-dark.svg');
|
||||
}
|
||||
|
||||
.vscode-high-contrast .loading-indicator {
|
||||
background-image: url('./loading-hc.svg');
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// @ts-check
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
// @ts-ignore
|
||||
const vscode = acquireVsCodeApi();
|
||||
|
||||
function getSettings() {
|
||||
const element = document.getElementById('settings');
|
||||
if (element) {
|
||||
const data = element.getAttribute('data-settings');
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not load settings`);
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
// State
|
||||
let hasLoadedMedia = false;
|
||||
|
||||
// Elements
|
||||
const container = document.createElement('div');
|
||||
container.className = 'audio-container';
|
||||
document.body.appendChild(container);
|
||||
|
||||
const audio = new Audio(settings.src === null ? undefined : settings.src);
|
||||
audio.controls = true;
|
||||
|
||||
function onLoaded() {
|
||||
if (hasLoadedMedia) {
|
||||
return;
|
||||
}
|
||||
hasLoadedMedia = true;
|
||||
|
||||
document.body.classList.remove('loading');
|
||||
document.body.classList.add('ready');
|
||||
container.append(audio);
|
||||
}
|
||||
|
||||
audio.addEventListener('error', e => {
|
||||
if (hasLoadedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasLoadedMedia = true;
|
||||
document.body.classList.add('error');
|
||||
document.body.classList.remove('loading');
|
||||
});
|
||||
|
||||
if (settings.isGitLfs) {
|
||||
hasLoadedMedia = true;
|
||||
document.body.classList.add('git-lfs');
|
||||
document.body.classList.remove('loading');
|
||||
} else if (settings.src === null) {
|
||||
onLoaded();
|
||||
} else {
|
||||
audio.addEventListener('canplaythrough', () => {
|
||||
onLoaded();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector('.open-file-link')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
vscode.postMessage({
|
||||
type: 'reopen-as-text',
|
||||
});
|
||||
});
|
||||
}());
|
||||
@@ -1,120 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body img {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.container:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 5px 0 0 10px;
|
||||
box-sizing: border-box;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.container.image {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container.image img {
|
||||
padding: 0;
|
||||
background-size: 16px 16px;
|
||||
border: 1px solid var(--vscode-imagePreview-border);
|
||||
}
|
||||
|
||||
.container.image img {
|
||||
background-image: conic-gradient(rgb(230, 230, 230) 25%, transparent 25% 50%, rgb(230, 230, 230) 50% 75%, transparent 75%);
|
||||
}
|
||||
|
||||
.vscode-dark.container.image img {
|
||||
background-image: conic-gradient(rgb(20, 20, 20) 25%, transparent 25% 50%, rgb(20, 20, 20) 50% 75%, transparent 75%);
|
||||
}
|
||||
|
||||
.container img.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.container img.scale-to-fit {
|
||||
max-width: calc(100% - 20px);
|
||||
max-height: calc(100% - 20px);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.container img:not(.scale-to-fit) {
|
||||
/* Zoomed images may be larger than the container. Keep them at their explicit size */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.container img {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.container.ready.zoom-in {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.container.ready.zoom-out {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.container .embedded-link,
|
||||
.container .embedded-link:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.container.loading,
|
||||
.container.error,
|
||||
.container.git-lfs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-image: url('./loading.svg');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.loading-indicator,
|
||||
.image-load-error,
|
||||
.git-lfs-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading .loading-indicator,
|
||||
.error .image-load-error,
|
||||
.git-lfs .git-lfs-info {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-load-error,
|
||||
.git-lfs-info {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
.vscode-dark .loading-indicator {
|
||||
background-image: url('./loading-dark.svg');
|
||||
}
|
||||
|
||||
.vscode-high-contrast .loading-indicator {
|
||||
background-image: url('./loading-hc.svg');
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// @ts-check
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @return {number}
|
||||
*/
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
const element = document.getElementById('image-preview-settings');
|
||||
if (element) {
|
||||
const data = element.getAttribute('data-settings');
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not load settings`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable image-rendering: pixelated for images scaled by more than this.
|
||||
*/
|
||||
const PIXELATION_THRESHOLD = 3;
|
||||
|
||||
const SCALE_PINCH_FACTOR = 0.075;
|
||||
const MAX_SCALE = 20;
|
||||
const MIN_SCALE = 0.1;
|
||||
|
||||
const zoomLevels = [
|
||||
0.1,
|
||||
0.2,
|
||||
0.3,
|
||||
0.4,
|
||||
0.5,
|
||||
0.6,
|
||||
0.7,
|
||||
0.8,
|
||||
0.9,
|
||||
1,
|
||||
1.5,
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
7,
|
||||
10,
|
||||
15,
|
||||
20
|
||||
];
|
||||
|
||||
const settings = getSettings();
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
|
||||
// @ts-ignore
|
||||
const vscode = acquireVsCodeApi();
|
||||
|
||||
const initialState = vscode.getState() || { scale: 'fit', offsetX: 0, offsetY: 0 };
|
||||
|
||||
// State
|
||||
let scale = initialState.scale;
|
||||
let ctrlPressed = false;
|
||||
let altPressed = false;
|
||||
let hasLoadedImage = false;
|
||||
let consumeClick = true;
|
||||
let isActive = false;
|
||||
|
||||
// Elements
|
||||
const container = document.body;
|
||||
const image = document.createElement('img');
|
||||
|
||||
/**
|
||||
* Whether the image needs an explicit size to be zoomed. Images without an intrinsic width,
|
||||
* such as SVGs with only a viewBox, otherwise stretch to fill the container, which cancels out
|
||||
* the zoom.
|
||||
*/
|
||||
let needsExplicitSize = false;
|
||||
|
||||
/**
|
||||
* Size of the image when it was last scaled to fit.
|
||||
*
|
||||
* @type {{ width?: number; height?: number } | undefined}
|
||||
*/
|
||||
let lastFitSize;
|
||||
|
||||
/**
|
||||
* Check if the image keeps its natural width in a container that is much wider than it.
|
||||
*/
|
||||
function hasIntrinsicWidth() {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.width = '100000px';
|
||||
probe.append(image);
|
||||
document.body.append(probe);
|
||||
const result = Math.abs(image.clientWidth - image.naturalWidth) <= 1;
|
||||
probe.remove();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @return {{ width?: number; height?: number }}
|
||||
*/
|
||||
function getLargerDimension(width, height) {
|
||||
return width >= height ? { width } : { height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of the image at a zoom level of 1.
|
||||
*
|
||||
* Only the larger dimension is returned as the other one follows from the aspect ratio. Natural
|
||||
* sizes are rounded, so the smaller dimension of a very thin SVG may otherwise be reported as 0.
|
||||
*
|
||||
* @return {{ width?: number; height?: number } | undefined}
|
||||
*/
|
||||
function getBaseSize() {
|
||||
if (image.classList.contains('scale-to-fit') && (image.clientWidth || image.clientHeight)) {
|
||||
lastFitSize = getLargerDimension(image.clientWidth, image.clientHeight);
|
||||
}
|
||||
|
||||
if (image.naturalWidth || image.naturalHeight) {
|
||||
return getLargerDimension(image.naturalWidth, image.naturalHeight);
|
||||
}
|
||||
|
||||
// Some browsers report no natural size for images without intrinsic dimensions (e.g. SVGs
|
||||
// with only a viewBox). Fall back to the size the image had when it was last scaled to fit.
|
||||
return lastFitSize;
|
||||
}
|
||||
|
||||
function updateScale(newScale) {
|
||||
if (!image || !hasLoadedImage || !image.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newScale === 'fit') {
|
||||
scale = 'fit';
|
||||
image.classList.add('scale-to-fit');
|
||||
image.classList.remove('pixelated');
|
||||
// @ts-ignore Non-standard CSS property
|
||||
image.style.zoom = 'normal';
|
||||
image.style.width = '';
|
||||
image.style.height = '';
|
||||
vscode.setState(undefined);
|
||||
} else {
|
||||
scale = clamp(newScale, MIN_SCALE, MAX_SCALE);
|
||||
if (scale >= PIXELATION_THRESHOLD) {
|
||||
image.classList.add('pixelated');
|
||||
} else {
|
||||
image.classList.remove('pixelated');
|
||||
}
|
||||
|
||||
const dx = (window.scrollX + container.clientWidth / 2) / container.scrollWidth;
|
||||
const dy = (window.scrollY + container.clientHeight / 2) / container.scrollHeight;
|
||||
|
||||
// Must be read before leaving scale-to-fit since it may depend on the fitted size
|
||||
const size = needsExplicitSize ? getBaseSize() : undefined;
|
||||
|
||||
image.classList.remove('scale-to-fit');
|
||||
|
||||
image.style.width = size?.width ? size.width + 'px' : '';
|
||||
image.style.height = size?.height ? size.height + 'px' : '';
|
||||
|
||||
// @ts-ignore Non-standard CSS property
|
||||
image.style.zoom = scale;
|
||||
|
||||
const newScrollX = container.scrollWidth * dx - container.clientWidth / 2;
|
||||
const newScrollY = container.scrollHeight * dy - container.clientHeight / 2;
|
||||
|
||||
window.scrollTo(newScrollX, newScrollY);
|
||||
|
||||
vscode.setState({ scale: scale, offsetX: newScrollX, offsetY: newScrollY });
|
||||
}
|
||||
|
||||
vscode.postMessage({
|
||||
type: 'zoom',
|
||||
value: scale
|
||||
});
|
||||
}
|
||||
|
||||
function setActive(value) {
|
||||
isActive = value;
|
||||
if (value) {
|
||||
if (isMac ? altPressed : ctrlPressed) {
|
||||
container.classList.remove('zoom-in');
|
||||
container.classList.add('zoom-out');
|
||||
} else {
|
||||
container.classList.remove('zoom-out');
|
||||
container.classList.add('zoom-in');
|
||||
}
|
||||
} else {
|
||||
ctrlPressed = false;
|
||||
altPressed = false;
|
||||
container.classList.remove('zoom-out');
|
||||
container.classList.remove('zoom-in');
|
||||
}
|
||||
}
|
||||
|
||||
function firstZoom() {
|
||||
if (!image || !hasLoadedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = needsExplicitSize ? getBaseSize() : { width: image.naturalWidth };
|
||||
if (size?.width) {
|
||||
scale = image.clientWidth / size.width;
|
||||
} else if (size?.height) {
|
||||
scale = image.clientHeight / size.height;
|
||||
} else {
|
||||
scale = 1;
|
||||
}
|
||||
updateScale(scale);
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (scale === 'fit') {
|
||||
firstZoom();
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (; i < zoomLevels.length; ++i) {
|
||||
if (zoomLevels[i] > scale) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
updateScale(zoomLevels[i] || MAX_SCALE);
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (scale === 'fit') {
|
||||
firstZoom();
|
||||
}
|
||||
|
||||
let i = zoomLevels.length - 1;
|
||||
for (; i >= 0; --i) {
|
||||
if (zoomLevels[i] < scale) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
updateScale(zoomLevels[i] || MIN_SCALE);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => {
|
||||
if (!image || !hasLoadedImage) {
|
||||
return;
|
||||
}
|
||||
ctrlPressed = e.ctrlKey;
|
||||
altPressed = e.altKey;
|
||||
|
||||
if (isMac ? altPressed : ctrlPressed) {
|
||||
container.classList.remove('zoom-in');
|
||||
container.classList.add('zoom-out');
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (/** @type {KeyboardEvent} */ e) => {
|
||||
if (!image || !hasLoadedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctrlPressed = e.ctrlKey;
|
||||
altPressed = e.altKey;
|
||||
|
||||
if (!(isMac ? altPressed : ctrlPressed)) {
|
||||
container.classList.remove('zoom-out');
|
||||
container.classList.add('zoom-in');
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('mousedown', (/** @type {MouseEvent} */ e) => {
|
||||
if (!image || !hasLoadedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctrlPressed = e.ctrlKey;
|
||||
altPressed = e.altKey;
|
||||
|
||||
consumeClick = !isActive;
|
||||
});
|
||||
|
||||
container.addEventListener('click', (/** @type {MouseEvent} */ e) => {
|
||||
if (!image || !hasLoadedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumeClick) {
|
||||
consumeClick = false;
|
||||
return;
|
||||
}
|
||||
// left click
|
||||
if (scale === 'fit') {
|
||||
firstZoom();
|
||||
}
|
||||
|
||||
if (!(isMac ? altPressed : ctrlPressed)) { // zoom in
|
||||
zoomIn();
|
||||
} else {
|
||||
zoomOut();
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('wheel', (/** @type {WheelEvent} */ e) => {
|
||||
// Prevent pinch to zoom
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!image || !hasLoadedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isScrollWheelKeyPressed = isMac ? altPressed : ctrlPressed;
|
||||
if (!isScrollWheelKeyPressed && !e.ctrlKey) { // pinching is reported as scroll wheel + ctrl
|
||||
return;
|
||||
}
|
||||
|
||||
if (scale === 'fit') {
|
||||
firstZoom();
|
||||
}
|
||||
|
||||
const delta = e.deltaY > 0 ? 1 : -1;
|
||||
updateScale(scale * (1 - delta * SCALE_PINCH_FACTOR));
|
||||
}, { passive: false });
|
||||
|
||||
window.addEventListener('scroll', e => {
|
||||
if (!image || !hasLoadedImage || !image.parentElement || scale === 'fit') {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = vscode.getState();
|
||||
if (entry) {
|
||||
vscode.setState({ scale: entry.scale, offsetX: window.scrollX, offsetY: window.scrollY });
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.classList.add('image');
|
||||
|
||||
image.classList.add('scale-to-fit');
|
||||
|
||||
image.addEventListener('load', () => {
|
||||
if (hasLoadedImage) {
|
||||
return;
|
||||
}
|
||||
hasLoadedImage = true;
|
||||
|
||||
vscode.postMessage({
|
||||
type: 'size',
|
||||
value: `${image.naturalWidth}x${image.naturalHeight}`,
|
||||
});
|
||||
|
||||
document.body.classList.remove('loading');
|
||||
document.body.classList.add('ready');
|
||||
needsExplicitSize = !hasIntrinsicWidth();
|
||||
document.body.append(image);
|
||||
|
||||
updateScale(scale);
|
||||
|
||||
if (initialState.scale !== 'fit') {
|
||||
window.scrollTo(initialState.offsetX, initialState.offsetY);
|
||||
}
|
||||
});
|
||||
|
||||
image.addEventListener('error', e => {
|
||||
if (hasLoadedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Error loading image', e);
|
||||
|
||||
hasLoadedImage = true;
|
||||
document.body.classList.add('error');
|
||||
document.body.classList.remove('loading');
|
||||
});
|
||||
|
||||
if (settings.isGitLfs) {
|
||||
hasLoadedImage = true;
|
||||
document.body.classList.add('git-lfs');
|
||||
document.body.classList.remove('loading');
|
||||
} else if (settings.src === null) {
|
||||
hasLoadedImage = true;
|
||||
document.body.classList.add('error');
|
||||
document.body.classList.remove('loading');
|
||||
} else {
|
||||
image.src = settings.src;
|
||||
}
|
||||
|
||||
document.querySelector('.open-file-link')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
vscode.postMessage({
|
||||
type: 'reopen-as-text',
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('message', e => {
|
||||
if (e.origin !== window.origin) {
|
||||
console.error('Dropping message from unknown origin in image preview');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.data.type) {
|
||||
case 'setScale': {
|
||||
updateScale(e.data.scale);
|
||||
break;
|
||||
}
|
||||
case 'setActive': {
|
||||
setActive(e.data.value);
|
||||
break;
|
||||
}
|
||||
case 'zoomIn': {
|
||||
zoomIn();
|
||||
break;
|
||||
}
|
||||
case 'zoomOut': {
|
||||
zoomOut();
|
||||
break;
|
||||
}
|
||||
case 'copyImage': {
|
||||
copyImage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('copy', () => {
|
||||
copyImage();
|
||||
});
|
||||
|
||||
async function copyImage(retries = 5) {
|
||||
if (!document.hasFocus() && retries > 0) {
|
||||
// copyImage is called at the same time as webview.reveal, which means this function is running whilst the webview is gaining focus.
|
||||
// Since navigator.clipboard.write requires the document to be focused, we need to wait for focus.
|
||||
// We cannot use a listener, as there is a high chance the focus is gained during the setup of the listener resulting in us missing it.
|
||||
setTimeout(() => { copyImage(retries - 1); }, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({
|
||||
'image/png': encodeImageAsPng()
|
||||
})]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the preview image as a PNG.
|
||||
*
|
||||
* On the main thread, Chromium encodes canvas PNGs using idle tasks. These can be starved for
|
||||
* seconds when the webview doesn't get idle time, delaying the copy. Workers encode directly,
|
||||
* so prefer encoding there and only fall back to the main thread if that fails.
|
||||
*
|
||||
* @return {Promise<Blob>}
|
||||
*/
|
||||
async function encodeImageAsPng() {
|
||||
if (typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined') {
|
||||
try {
|
||||
// Draw on the main thread so EXIF orientation, sRGB conversion and the copied animation
|
||||
// frame match what was previously copied. `createImageBitmap(image)` differs on these.
|
||||
const canvas = new OffscreenCanvas(image.naturalWidth, image.naturalHeight);
|
||||
/** @type {OffscreenCanvasRenderingContext2D} */ (canvas.getContext('2d')).drawImage(image, 0, 0);
|
||||
return await encodePngInWorker(canvas.transferToImageBitmap());
|
||||
} catch (e) {
|
||||
console.warn('Failed to encode image in worker, falling back to main thread', e);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
/** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')).drawImage(image, 0, 0);
|
||||
canvas.toBlob((blob) => {
|
||||
canvas.width = canvas.height = 0;
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Failed to encode image'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
// Avoid a 'bitmaprenderer' context here: in Firefox, convertToBlob() on one can fail to settle.
|
||||
const pngEncoderWorkerSource = `
|
||||
self.onmessage = async (e) => {
|
||||
const bitmap = e.data;
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
// Free the source pixels before encoding so only one full-size copy is alive during the encode.
|
||||
bitmap.close();
|
||||
self.postMessage({ blob: await canvas.convertToBlob({ type: 'image/png' }) });
|
||||
} catch (err) {
|
||||
bitmap.close();
|
||||
self.postMessage({ error: String(err) });
|
||||
}
|
||||
};`;
|
||||
|
||||
/**
|
||||
* @param {ImageBitmap} bitmap Transferred to the worker.
|
||||
* @return {Promise<Blob>}
|
||||
*/
|
||||
function encodePngInWorker(bitmap) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerUrl = URL.createObjectURL(new Blob([pngEncoderWorkerSource], { type: 'text/javascript' }));
|
||||
/** @type {Worker | undefined} */
|
||||
let worker;
|
||||
|
||||
const dispose = () => {
|
||||
worker?.terminate();
|
||||
URL.revokeObjectURL(workerUrl);
|
||||
};
|
||||
|
||||
try {
|
||||
worker = new Worker(workerUrl);
|
||||
worker.onmessage = (e) => {
|
||||
dispose();
|
||||
if (e.data?.blob) {
|
||||
resolve(e.data.blob);
|
||||
} else {
|
||||
reject(new Error(e.data?.error ?? 'Failed to encode image'));
|
||||
}
|
||||
};
|
||||
worker.onerror = (e) => {
|
||||
dispose();
|
||||
reject(new Error(e.message || 'Image encoder worker failed'));
|
||||
};
|
||||
// Throws synchronously if the bitmap can't be transferred, e.g. a DataCloneError
|
||||
// for a bitmap that isn't origin-clean. Neither worker handler runs in that case.
|
||||
worker.postMessage(bitmap, [bitmap]);
|
||||
} catch (e) {
|
||||
dispose();
|
||||
// No-op if the bitmap was already transferred.
|
||||
bitmap.close();
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}());
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version='1.0' standalone='no' ?>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='10px' height='10px'>
|
||||
<style>
|
||||
circle {
|
||||
animation: ball 0.6s linear infinite;
|
||||
}
|
||||
|
||||
circle:nth-child(2) { animation-delay: 0.075s; }
|
||||
circle:nth-child(3) { animation-delay: 0.15s; }
|
||||
circle:nth-child(4) { animation-delay: 0.225s; }
|
||||
circle:nth-child(5) { animation-delay: 0.3s; }
|
||||
circle:nth-child(6) { animation-delay: 0.375s; }
|
||||
circle:nth-child(7) { animation-delay: 0.45s; }
|
||||
circle:nth-child(8) { animation-delay: 0.525s; }
|
||||
|
||||
@keyframes ball {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
<g style="fill:grey;">
|
||||
<circle cx='5' cy='1' r='1' style='opacity:0.3;' />
|
||||
<circle cx='7.8284' cy='2.1716' r='1' style='opacity:0.3;' />
|
||||
<circle cx='9' cy='5' r='1' style='opacity:0.3;' />
|
||||
<circle cx='7.8284' cy='7.8284' r='1' style='opacity:0.3;' />
|
||||
<circle cx='5' cy='9' r='1' style='opacity:0.3;' />
|
||||
<circle cx='2.1716' cy='7.8284' r='1' style='opacity:0.3;' />
|
||||
<circle cx='1' cy='5' r='1' style='opacity:0.3;' />
|
||||
<circle cx='2.1716' cy='2.1716' r='1' style='opacity:0.3;' />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,31 +0,0 @@
|
||||
<?xml version='1.0' standalone='no' ?>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='10px' height='10px'>
|
||||
<style>
|
||||
circle {
|
||||
animation: ball 0.6s linear infinite;
|
||||
}
|
||||
|
||||
circle:nth-child(2) { animation-delay: 0.075s; }
|
||||
circle:nth-child(3) { animation-delay: 0.15s; }
|
||||
circle:nth-child(4) { animation-delay: 0.225s; }
|
||||
circle:nth-child(5) { animation-delay: 0.3s; }
|
||||
circle:nth-child(6) { animation-delay: 0.375s; }
|
||||
circle:nth-child(7) { animation-delay: 0.45s; }
|
||||
circle:nth-child(8) { animation-delay: 0.525s; }
|
||||
|
||||
@keyframes ball {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
<g style="fill:white;">
|
||||
<circle cx='5' cy='1' r='1' style='opacity:0.3;' />
|
||||
<circle cx='7.8284' cy='2.1716' r='1' style='opacity:0.3;' />
|
||||
<circle cx='9' cy='5' r='1' style='opacity:0.3;' />
|
||||
<circle cx='7.8284' cy='7.8284' r='1' style='opacity:0.3;' />
|
||||
<circle cx='5' cy='9' r='1' style='opacity:0.3;' />
|
||||
<circle cx='2.1716' cy='7.8284' r='1' style='opacity:0.3;' />
|
||||
<circle cx='1' cy='5' r='1' style='opacity:0.3;' />
|
||||
<circle cx='2.1716' cy='2.1716' r='1' style='opacity:0.3;' />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,31 +0,0 @@
|
||||
<?xml version='1.0' standalone='no' ?>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='10px' height='10px'>
|
||||
<style>
|
||||
circle {
|
||||
animation: ball 0.6s linear infinite;
|
||||
}
|
||||
|
||||
circle:nth-child(2) { animation-delay: 0.075s; }
|
||||
circle:nth-child(3) { animation-delay: 0.15s; }
|
||||
circle:nth-child(4) { animation-delay: 0.225s; }
|
||||
circle:nth-child(5) { animation-delay: 0.3s; }
|
||||
circle:nth-child(6) { animation-delay: 0.375s; }
|
||||
circle:nth-child(7) { animation-delay: 0.45s; }
|
||||
circle:nth-child(8) { animation-delay: 0.525s; }
|
||||
|
||||
@keyframes ball {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
<g>
|
||||
<circle cx='5' cy='1' r='1' style='opacity:0.3;' />
|
||||
<circle cx='7.8284' cy='2.1716' r='1' style='opacity:0.3;' />
|
||||
<circle cx='9' cy='5' r='1' style='opacity:0.3;' />
|
||||
<circle cx='7.8284' cy='7.8284' r='1' style='opacity:0.3;' />
|
||||
<circle cx='5' cy='9' r='1' style='opacity:0.3;' />
|
||||
<circle cx='2.1716' cy='7.8284' r='1' style='opacity:0.3;' />
|
||||
<circle cx='1' cy='5' r='1' style='opacity:0.3;' />
|
||||
<circle cx='2.1716' cy='2.1716' r='1' style='opacity:0.3;' />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,53 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 5px 10px;
|
||||
width: calc(100% - 20px);
|
||||
height: calc(100% - 10px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-image: url('./loading.svg');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.loading-indicator,
|
||||
.loading-error,
|
||||
.git-lfs-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading .loading-indicator,
|
||||
.error .loading-error,
|
||||
.git-lfs .git-lfs-info {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading-error,
|
||||
.git-lfs-info {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
.vscode-dark .loading-indicator {
|
||||
background-image: url('./loading-dark.svg');
|
||||
}
|
||||
|
||||
.vscode-high-contrast .loading-indicator {
|
||||
background-image: url('./loading-hc.svg');
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// @ts-check
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
// @ts-ignore
|
||||
const vscode = acquireVsCodeApi();
|
||||
|
||||
function getSettings() {
|
||||
const element = document.getElementById('settings');
|
||||
if (element) {
|
||||
const data = element.getAttribute('data-settings');
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not load settings`);
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
// State
|
||||
let hasLoadedMedia = false;
|
||||
|
||||
// Elements
|
||||
const video = document.createElement('video');
|
||||
if (settings.src !== null) {
|
||||
video.src = settings.src;
|
||||
}
|
||||
video.playsInline = true;
|
||||
video.controls = true;
|
||||
video.autoplay = settings.autoplay;
|
||||
video.muted = settings.autoplay;
|
||||
video.loop = settings.loop;
|
||||
|
||||
function onLoaded() {
|
||||
if (hasLoadedMedia) {
|
||||
return;
|
||||
}
|
||||
hasLoadedMedia = true;
|
||||
|
||||
document.body.classList.remove('loading');
|
||||
document.body.classList.add('ready');
|
||||
document.body.append(video);
|
||||
}
|
||||
|
||||
video.addEventListener('error', e => {
|
||||
if (hasLoadedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasLoadedMedia = true;
|
||||
document.body.classList.add('error');
|
||||
document.body.classList.remove('loading');
|
||||
});
|
||||
|
||||
if (settings.isGitLfs) {
|
||||
hasLoadedMedia = true;
|
||||
document.body.classList.add('git-lfs');
|
||||
document.body.classList.remove('loading');
|
||||
} else if (settings.src === null) {
|
||||
onLoaded();
|
||||
} else {
|
||||
video.addEventListener('canplaythrough', () => {
|
||||
onLoaded();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector('.open-file-link')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
vscode.postMessage({
|
||||
type: 'reopen-as-text',
|
||||
});
|
||||
});
|
||||
}());
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
{
|
||||
"name": "media-preview",
|
||||
"version": "10.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-preview",
|
||||
"version": "10.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vscode/extension-telemetry": "^0.9.8",
|
||||
"vscode-uri": "^3.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.x"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.70.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/1ds-core-js": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-4.3.4.tgz",
|
||||
"integrity": "sha512-3gbDUQgAO8EoyQTNcAEkxpuPnioC0May13P1l1l0NKZ128L9Ts/sj8QsfwCRTjHz0HThlA+4FptcAJXNYUy3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/applicationinsights-core-js": "3.3.4",
|
||||
"@microsoft/applicationinsights-shims": "3.0.1",
|
||||
"@microsoft/dynamicproto-js": "^2.0.3",
|
||||
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
|
||||
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/1ds-post-js": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/1ds-post-js/-/1ds-post-js-4.3.4.tgz",
|
||||
"integrity": "sha512-nlKjWricDj0Tn68Dt0P8lX9a+X7LYrqJ6/iSfQwMfDhRIGLqW+wxx8gxS+iGWC/oc8zMQAeiZaemUpCwQcwpRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/1ds-core-js": "4.3.4",
|
||||
"@microsoft/applicationinsights-shims": "3.0.1",
|
||||
"@microsoft/dynamicproto-js": "^2.0.3",
|
||||
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
|
||||
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/applicationinsights-channel-js": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-3.3.4.tgz",
|
||||
"integrity": "sha512-Z4nrxYwGKP9iyrYtm7iPQXVOFy4FsEsX0nDKkAi96Qpgw+vEh6NH4ORxMMuES0EollBQ3faJyvYCwckuCVIj0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/applicationinsights-common": "3.3.4",
|
||||
"@microsoft/applicationinsights-core-js": "3.3.4",
|
||||
"@microsoft/applicationinsights-shims": "3.0.1",
|
||||
"@microsoft/dynamicproto-js": "^2.0.3",
|
||||
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
|
||||
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tslib": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/applicationinsights-common": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-common/-/applicationinsights-common-3.3.4.tgz",
|
||||
"integrity": "sha512-4ms16MlIvcP4WiUPqopifNxcWCcrXQJ2ADAK/75uok2mNQe6ZNRsqb/P+pvhUxc8A5HRlvoXPP1ptDSN5Girgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/applicationinsights-core-js": "3.3.4",
|
||||
"@microsoft/applicationinsights-shims": "3.0.1",
|
||||
"@microsoft/dynamicproto-js": "^2.0.3",
|
||||
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tslib": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/applicationinsights-core-js": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-3.3.4.tgz",
|
||||
"integrity": "sha512-MummANF0mgKIkdvVvfmHQTBliK114IZLRhTL0X0Ep+zjDwWMHqYZgew0nlFKAl6ggu42abPZFK5afpE7qjtYJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/applicationinsights-shims": "3.0.1",
|
||||
"@microsoft/dynamicproto-js": "^2.0.3",
|
||||
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
|
||||
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tslib": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/applicationinsights-shims": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-shims/-/applicationinsights-shims-3.0.1.tgz",
|
||||
"integrity": "sha512-DKwboF47H1nb33rSUfjqI6ryX29v+2QWcTrRvcQDA32AZr5Ilkr7whOOSsD1aBzwqX0RJEIP1Z81jfE3NBm/Lg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nevware21/ts-utils": ">= 0.9.4 < 2.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/applicationinsights-web-basic": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-3.3.4.tgz",
|
||||
"integrity": "sha512-OpEPXr8vU/t/M8T9jvWJzJx/pCyygIiR1nGM/2PTde0wn7anl71Gxl5fWol7K/WwFEORNjkL3CEyWOyDc+28AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/applicationinsights-channel-js": "3.3.4",
|
||||
"@microsoft/applicationinsights-common": "3.3.4",
|
||||
"@microsoft/applicationinsights-core-js": "3.3.4",
|
||||
"@microsoft/applicationinsights-shims": "3.0.1",
|
||||
"@microsoft/dynamicproto-js": "^2.0.3",
|
||||
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
|
||||
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tslib": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/dynamicproto-js": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-2.0.3.tgz",
|
||||
"integrity": "sha512-JTWTU80rMy3mdxOjjpaiDQsTLZ6YSGGqsjURsY6AUQtIj0udlF/jYmhdLZu8693ZIC0T1IwYnFa0+QeiMnziBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nevware21/ts-utils": ">= 0.10.4 < 2.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@nevware21/ts-async": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@nevware21/ts-async/-/ts-async-0.5.4.tgz",
|
||||
"integrity": "sha512-IBTyj29GwGlxfzXw2NPnzty+w0Adx61Eze1/lknH/XIVdxtF9UnOpk76tnrHXWa6j84a1RR9hsOcHQPFv9qJjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nevware21/ts-utils": ">= 0.11.6 < 2.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@nevware21/ts-utils": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz",
|
||||
"integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nevware21"
|
||||
},
|
||||
{
|
||||
"type": "other",
|
||||
"url": "https://buymeacoffee.com/nevware21"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/extension-telemetry": {
|
||||
"version": "0.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/extension-telemetry/-/extension-telemetry-0.9.8.tgz",
|
||||
"integrity": "sha512-7YcKoUvmHlIB8QYCE4FNzt3ErHi9gQPhdCM3ZWtpw1bxPT0I+lMdx52KHlzTNoJzQ2NvMX7HyzyDwBEiMgTrWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/1ds-core-js": "^4.3.4",
|
||||
"@microsoft/1ds-post-js": "^4.3.4",
|
||||
"@microsoft/applicationinsights-web-basic": "^3.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.75.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.6.tgz",
|
||||
"integrity": "sha512-fmL7V1eiDBFRRnu+gfRWTzyPpNIHJTc4mWnFkwBUmO9U3KPgJAmTx7oxi2bl/Rh6HLdU7+4C9wlj0k2E4AdKFQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
{
|
||||
"name": "media-preview",
|
||||
"displayName": "%displayName%",
|
||||
"description": "%description%",
|
||||
"extensionKind": [
|
||||
"ui",
|
||||
"workspace"
|
||||
],
|
||||
"version": "10.0.0",
|
||||
"publisher": "vscode",
|
||||
"icon": "icon.png",
|
||||
"license": "MIT",
|
||||
"aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255",
|
||||
"engines": {
|
||||
"vscode": "^1.70.0"
|
||||
},
|
||||
"main": "./out/extension",
|
||||
"browser": "./dist/browser/extension.js",
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"activationEvents": [],
|
||||
"capabilities": {
|
||||
"virtualWorkspaces": true,
|
||||
"untrustedWorkspaces": {
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
"contributes": {
|
||||
"configuration": {
|
||||
"type": "object",
|
||||
"title": "Media Previewer",
|
||||
"properties": {
|
||||
"mediaPreview.video.autoPlay": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "%videoPreviewerAutoPlay%"
|
||||
},
|
||||
"mediaPreview.video.loop": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "%videoPreviewerLoop%"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customEditors": [
|
||||
{
|
||||
"viewType": "imagePreview.previewEditor",
|
||||
"displayName": "%customEditor.imagePreview.displayName%",
|
||||
"priority": "builtin",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.{jpg,jpe,jpeg,png,bmp,gif,ico,webp,avif,svg}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"viewType": "vscode.audioPreview",
|
||||
"displayName": "%customEditor.audioPreview.displayName%",
|
||||
"priority": "builtin",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.{mp3,wav,ogg,oga}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"viewType": "vscode.videoPreview",
|
||||
"displayName": "%customEditor.videoPreview.displayName%",
|
||||
"priority": "builtin",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.{mp4,webm}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "imagePreview.zoomIn",
|
||||
"title": "%command.zoomIn%",
|
||||
"category": "Image Preview"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.zoomOut",
|
||||
"title": "%command.zoomOut%",
|
||||
"category": "Image Preview"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.copyImage",
|
||||
"title": "%command.copyImage%",
|
||||
"category": "Image Preview"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.reopenAsPreview",
|
||||
"title": "%command.reopenAsPreview%",
|
||||
"category": "Image Preview",
|
||||
"icon": "$(preview)"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.reopenAsText",
|
||||
"title": "%command.reopenAsText%",
|
||||
"category": "Image Preview",
|
||||
"icon": "$(go-to-file)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"commandPalette": [
|
||||
{
|
||||
"command": "imagePreview.zoomIn",
|
||||
"when": "activeCustomEditorId == 'imagePreview.previewEditor'",
|
||||
"group": "1_imagePreview"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.zoomOut",
|
||||
"when": "activeCustomEditorId == 'imagePreview.previewEditor'",
|
||||
"group": "1_imagePreview"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.copyImage",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.reopenAsPreview",
|
||||
"when": "activeEditor == workbench.editors.files.textFileEditor && resourceExtname == '.svg' && !hasCustomImagePreview",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.reopenAsText",
|
||||
"when": "activeCustomEditorId == 'imagePreview.previewEditor' && resourceExtname == '.svg'",
|
||||
"group": "navigation"
|
||||
}
|
||||
],
|
||||
"webview/context": [
|
||||
{
|
||||
"command": "imagePreview.copyImage",
|
||||
"when": "webviewId == 'imagePreview.previewEditor'"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "imagePreview.reopenAsPreview",
|
||||
"when": "editorFocus && resourceExtname == '.svg' && !hasCustomImagePreview",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "imagePreview.reopenAsText",
|
||||
"when": "activeCustomEditorId == 'imagePreview.previewEditor' && resourceExtname == '.svg'",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "gulp compile-extension:media-preview",
|
||||
"watch": "npm run build-preview && gulp watch-extension:media-preview",
|
||||
"vscode:prepublish": "npm run build-ext",
|
||||
"build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:media-preview ./tsconfig.json",
|
||||
"compile-web": "npm-run-all2 -lp bundle-web typecheck-web",
|
||||
"bundle-web": "node ./esbuild.browser.mts",
|
||||
"typecheck-web": "node ../../node_modules/@typescript/native/lib/tsc.js --project ./tsconfig.browser.json --noEmit",
|
||||
"watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web",
|
||||
"watch-bundle-web": "node ./esbuild.browser.mts --watch",
|
||||
"watch-typecheck-web": "node ../../node_modules/@typescript/native/lib/tsc.js --project ./tsconfig.browser.json --noEmit --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/extension-telemetry": "^0.9.8",
|
||||
"vscode-uri": "^3.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.x"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/microsoft/vscode.git"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"displayName": "Media Preview",
|
||||
"description": "Provides VS Code's built-in previews for images, audio, and video",
|
||||
"customEditor.audioPreview.displayName": "Audio Preview",
|
||||
"customEditor.imagePreview.displayName": "Image Preview",
|
||||
"customEditor.videoPreview.displayName": "Video Preview",
|
||||
"videoPreviewerAutoPlay": "Start playing videos on mute automatically.",
|
||||
"videoPreviewerLoop": "Loop videos over again automatically.",
|
||||
"command.zoomIn": "Zoom in",
|
||||
"command.zoomOut": "Zoom out",
|
||||
"command.copyImage": "Copy",
|
||||
"command.reopenAsPreview": "Reopen as image preview",
|
||||
"command.reopenAsText": "Reopen as source text"
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { BinarySizeStatusBarEntry } from './binarySizeStatusBarEntry';
|
||||
import { MediaPreview, isGitLfsPointer, reopenAsText } from './mediaPreview';
|
||||
import { escapeAttribute } from './util/dom';
|
||||
import { generateUuid } from './util/uuid';
|
||||
|
||||
class AudioPreviewProvider implements vscode.CustomReadonlyEditorProvider {
|
||||
|
||||
public static readonly viewType = 'vscode.audioPreview';
|
||||
|
||||
constructor(
|
||||
private readonly extensionRoot: vscode.Uri,
|
||||
private readonly binarySizeStatusBarEntry: BinarySizeStatusBarEntry,
|
||||
) { }
|
||||
|
||||
public async openCustomDocument(uri: vscode.Uri) {
|
||||
return { uri, dispose: () => { } };
|
||||
}
|
||||
|
||||
public async resolveCustomEditor(document: vscode.CustomDocument, webviewEditor: vscode.WebviewPanel): Promise<void> {
|
||||
new AudioPreview(this.extensionRoot, document.uri, webviewEditor, this.binarySizeStatusBarEntry);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AudioPreview extends MediaPreview {
|
||||
|
||||
constructor(
|
||||
private readonly extensionRoot: vscode.Uri,
|
||||
resource: vscode.Uri,
|
||||
webviewEditor: vscode.WebviewPanel,
|
||||
binarySizeStatusBarEntry: BinarySizeStatusBarEntry,
|
||||
) {
|
||||
super(extensionRoot, resource, webviewEditor, binarySizeStatusBarEntry);
|
||||
|
||||
this._register(webviewEditor.webview.onDidReceiveMessage(message => {
|
||||
switch (message.type) {
|
||||
case 'reopen-as-text': {
|
||||
reopenAsText(resource, webviewEditor.viewColumn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this.updateBinarySize();
|
||||
this.render();
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
protected async getWebviewContents(): Promise<string> {
|
||||
const version = Date.now().toString();
|
||||
const src = await this.getResourcePath(this._webviewEditor, this._resource, version);
|
||||
const settings = {
|
||||
src,
|
||||
isGitLfs: src === null,
|
||||
};
|
||||
|
||||
const nonce = generateUuid();
|
||||
|
||||
const cspSource = this._webviewEditor.webview.cspSource;
|
||||
return /* html */`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<!-- Disable pinch zooming -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
|
||||
<title>Audio Preview</title>
|
||||
|
||||
<link rel="stylesheet" href="${escapeAttribute(this.extensionResource('media', 'audioPreview.css'))}" type="text/css" media="screen" nonce="${nonce}">
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: ${cspSource}; media-src ${cspSource}; script-src 'nonce-${nonce}'; style-src ${cspSource} 'nonce-${nonce}';">
|
||||
<meta id="settings" data-settings="${escapeAttribute(JSON.stringify(settings))}">
|
||||
</head>
|
||||
<body class="container loading" data-vscode-context='{ "preventDefaultContextMenuItems": true }'>
|
||||
<div class="loading-indicator"></div>
|
||||
<div class="loading-error">
|
||||
<p>${vscode.l10n.t("An error occurred while loading the audio file.")}</p>
|
||||
<a href="#" class="open-file-link">${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")}</a>
|
||||
</div>
|
||||
<div class="git-lfs-info">
|
||||
<p>${vscode.l10n.t("The audio file is stored with Git LFS and is not available for preview.")}</p>
|
||||
<a href="#" class="open-file-link">${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")}</a>
|
||||
</div>
|
||||
<script src="${escapeAttribute(this.extensionResource('media', 'audioPreview.js'))}" nonce="${nonce}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private async getResourcePath(webviewEditor: vscode.WebviewPanel, resource: vscode.Uri, version: string): Promise<string | null> {
|
||||
if (await isGitLfsPointer(resource)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Avoid adding cache busting if there is already a query string
|
||||
if (resource.query) {
|
||||
return webviewEditor.webview.asWebviewUri(resource).toString();
|
||||
}
|
||||
return webviewEditor.webview.asWebviewUri(resource).with({ query: `version=${version}` }).toString();
|
||||
}
|
||||
|
||||
private extensionResource(...parts: string[]) {
|
||||
return this._webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAudioPreviewSupport(context: vscode.ExtensionContext, binarySizeStatusBarEntry: BinarySizeStatusBarEntry): vscode.Disposable {
|
||||
const provider = new AudioPreviewProvider(context.extensionUri, binarySizeStatusBarEntry);
|
||||
return vscode.window.registerCustomEditorProvider(AudioPreviewProvider.viewType, provider, {
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
webviewOptions: {
|
||||
retainContextWhenHidden: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { PreviewStatusBarEntry } from './ownedStatusBarEntry';
|
||||
|
||||
|
||||
class BinarySize {
|
||||
static readonly KB = 1024;
|
||||
static readonly MB = BinarySize.KB * BinarySize.KB;
|
||||
static readonly GB = BinarySize.MB * BinarySize.KB;
|
||||
static readonly TB = BinarySize.GB * BinarySize.KB;
|
||||
|
||||
static formatSize(size: number): string {
|
||||
if (size < BinarySize.KB) {
|
||||
return vscode.l10n.t("{0}B", size);
|
||||
}
|
||||
|
||||
if (size < BinarySize.MB) {
|
||||
return vscode.l10n.t("{0}KB", (size / BinarySize.KB).toFixed(2));
|
||||
}
|
||||
|
||||
if (size < BinarySize.GB) {
|
||||
return vscode.l10n.t("{0}MB", (size / BinarySize.MB).toFixed(2));
|
||||
}
|
||||
|
||||
if (size < BinarySize.TB) {
|
||||
return vscode.l10n.t("{0}GB", (size / BinarySize.GB).toFixed(2));
|
||||
}
|
||||
|
||||
return vscode.l10n.t("{0}TB", (size / BinarySize.TB).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
export class BinarySizeStatusBarEntry extends PreviewStatusBarEntry {
|
||||
|
||||
constructor() {
|
||||
super('status.imagePreview.binarySize', vscode.l10n.t("Image Binary Size"), vscode.StatusBarAlignment.Right, 100);
|
||||
}
|
||||
|
||||
public show(owner: unknown, size: number | undefined) {
|
||||
if (typeof size === 'number') {
|
||||
super.showItem(owner, BinarySize.formatSize(size));
|
||||
} else {
|
||||
this.hide(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { registerAudioPreviewSupport } from './audioPreview';
|
||||
import { BinarySizeStatusBarEntry } from './binarySizeStatusBarEntry';
|
||||
import { registerImagePreviewSupport } from './imagePreview';
|
||||
import { registerVideoPreviewSupport } from './videoPreview';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
const binarySizeStatusBarEntry = new BinarySizeStatusBarEntry();
|
||||
context.subscriptions.push(binarySizeStatusBarEntry);
|
||||
|
||||
context.subscriptions.push(registerImagePreviewSupport(context, binarySizeStatusBarEntry));
|
||||
context.subscriptions.push(registerAudioPreviewSupport(context, binarySizeStatusBarEntry));
|
||||
context.subscriptions.push(registerVideoPreviewSupport(context, binarySizeStatusBarEntry));
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { BinarySizeStatusBarEntry } from '../binarySizeStatusBarEntry';
|
||||
import { MediaPreview, PreviewState, isGitLfsPointer, reopenAsText } from '../mediaPreview';
|
||||
import { escapeAttribute } from '../util/dom';
|
||||
import { generateUuid } from '../util/uuid';
|
||||
import { SizeStatusBarEntry } from './sizeStatusBarEntry';
|
||||
import { Scale, ZoomStatusBarEntry } from './zoomStatusBarEntry';
|
||||
|
||||
|
||||
export class ImagePreviewManager implements vscode.CustomReadonlyEditorProvider {
|
||||
|
||||
public static readonly viewType = 'imagePreview.previewEditor';
|
||||
|
||||
private readonly _previews = new Set<ImagePreview>();
|
||||
private _activePreview: ImagePreview | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly extensionRoot: vscode.Uri,
|
||||
private readonly sizeStatusBarEntry: SizeStatusBarEntry,
|
||||
private readonly binarySizeStatusBarEntry: BinarySizeStatusBarEntry,
|
||||
private readonly zoomStatusBarEntry: ZoomStatusBarEntry,
|
||||
) { }
|
||||
|
||||
public async openCustomDocument(uri: vscode.Uri) {
|
||||
return { uri, dispose: () => { } };
|
||||
}
|
||||
|
||||
public async resolveCustomEditor(
|
||||
document: vscode.CustomDocument,
|
||||
webviewEditor: vscode.WebviewPanel,
|
||||
): Promise<void> {
|
||||
const preview = new ImagePreview(this.extensionRoot, document.uri, webviewEditor, this.sizeStatusBarEntry, this.binarySizeStatusBarEntry, this.zoomStatusBarEntry);
|
||||
this._previews.add(preview);
|
||||
this.setActivePreview(preview);
|
||||
|
||||
webviewEditor.onDidDispose(() => { this._previews.delete(preview); });
|
||||
|
||||
webviewEditor.onDidChangeViewState(() => {
|
||||
if (webviewEditor.active) {
|
||||
this.setActivePreview(preview);
|
||||
} else if (this._activePreview === preview && !webviewEditor.active) {
|
||||
this.setActivePreview(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public get activePreview() {
|
||||
return this._activePreview;
|
||||
}
|
||||
|
||||
public getPreviewFor(resource: vscode.Uri, viewColumn?: vscode.ViewColumn): ImagePreview | undefined {
|
||||
for (const preview of this._previews) {
|
||||
if (preview.resource.toString() === resource.toString()) {
|
||||
if (!viewColumn || preview.viewColumn === viewColumn) {
|
||||
return preview;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private setActivePreview(value: ImagePreview | undefined): void {
|
||||
this._activePreview = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ImagePreview extends MediaPreview {
|
||||
|
||||
private _imageSize: string | undefined;
|
||||
private _imageZoom: Scale | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly extensionRoot: vscode.Uri,
|
||||
resource: vscode.Uri,
|
||||
webviewEditor: vscode.WebviewPanel,
|
||||
private readonly sizeStatusBarEntry: SizeStatusBarEntry,
|
||||
binarySizeStatusBarEntry: BinarySizeStatusBarEntry,
|
||||
private readonly zoomStatusBarEntry: ZoomStatusBarEntry,
|
||||
) {
|
||||
super(extensionRoot, resource, webviewEditor, binarySizeStatusBarEntry);
|
||||
|
||||
this._register(webviewEditor.webview.onDidReceiveMessage(message => {
|
||||
switch (message.type) {
|
||||
case 'size': {
|
||||
this._imageSize = message.value;
|
||||
this.updateState();
|
||||
break;
|
||||
}
|
||||
case 'zoom': {
|
||||
this._imageZoom = message.value;
|
||||
this.updateState();
|
||||
break;
|
||||
}
|
||||
case 'reopen-as-text': {
|
||||
reopenAsText(resource, webviewEditor.viewColumn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(zoomStatusBarEntry.onDidChangeScale(e => {
|
||||
if (this.previewState === PreviewState.Active) {
|
||||
this._webviewEditor.webview.postMessage({ type: 'setScale', scale: e.scale });
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(webviewEditor.onDidChangeViewState(() => {
|
||||
this._webviewEditor.webview.postMessage({ type: 'setActive', value: this._webviewEditor.active });
|
||||
}));
|
||||
|
||||
this._register(webviewEditor.onDidDispose(() => {
|
||||
if (this.previewState === PreviewState.Active) {
|
||||
this.sizeStatusBarEntry.hide(this);
|
||||
this.zoomStatusBarEntry.hide(this);
|
||||
}
|
||||
this.previewState = PreviewState.Disposed;
|
||||
}));
|
||||
|
||||
this.updateBinarySize();
|
||||
this.render();
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
public override dispose(): void {
|
||||
super.dispose();
|
||||
this.sizeStatusBarEntry.hide(this);
|
||||
this.zoomStatusBarEntry.hide(this);
|
||||
}
|
||||
|
||||
public get viewColumn() {
|
||||
return this._webviewEditor.viewColumn;
|
||||
}
|
||||
|
||||
public zoomIn() {
|
||||
if (this.previewState === PreviewState.Active) {
|
||||
this._webviewEditor.webview.postMessage({ type: 'zoomIn' });
|
||||
}
|
||||
}
|
||||
|
||||
public zoomOut() {
|
||||
if (this.previewState === PreviewState.Active) {
|
||||
this._webviewEditor.webview.postMessage({ type: 'zoomOut' });
|
||||
}
|
||||
}
|
||||
|
||||
public copyImage() {
|
||||
if (this.previewState === PreviewState.Active) {
|
||||
this._webviewEditor.reveal();
|
||||
this._webviewEditor.webview.postMessage({ type: 'copyImage' });
|
||||
}
|
||||
}
|
||||
|
||||
protected override updateState() {
|
||||
super.updateState();
|
||||
|
||||
if (this.previewState === PreviewState.Disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._webviewEditor.active) {
|
||||
this.sizeStatusBarEntry.show(this, this._imageSize || '');
|
||||
this.zoomStatusBarEntry.show(this, this._imageZoom || 'fit');
|
||||
} else {
|
||||
this.sizeStatusBarEntry.hide(this);
|
||||
this.zoomStatusBarEntry.hide(this);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async render(): Promise<void> {
|
||||
await super.render();
|
||||
this._webviewEditor.webview.postMessage({ type: 'setActive', value: this._webviewEditor.active });
|
||||
}
|
||||
|
||||
protected override async getWebviewContents(): Promise<string> {
|
||||
const version = Date.now().toString();
|
||||
const src = await this.getResourcePath(this._webviewEditor, this._resource, version);
|
||||
const settings = {
|
||||
src,
|
||||
isGitLfs: src === null,
|
||||
};
|
||||
|
||||
const nonce = generateUuid();
|
||||
|
||||
const cspSource = this._webviewEditor.webview.cspSource;
|
||||
return /* html */`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<!-- Disable pinch zooming -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
|
||||
<title>Image Preview</title>
|
||||
|
||||
<link rel="stylesheet" href="${escapeAttribute(this.extensionResource('media', 'imagePreview.css'))}" type="text/css" media="screen" nonce="${nonce}">
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: ${cspSource}; connect-src ${cspSource}; script-src 'nonce-${nonce}'; worker-src blob:; style-src ${cspSource} 'nonce-${nonce}';">
|
||||
<meta id="image-preview-settings" data-settings="${escapeAttribute(JSON.stringify(settings))}">
|
||||
</head>
|
||||
<body class="container image scale-to-fit loading" data-vscode-context='{ "preventDefaultContextMenuItems": true }'>
|
||||
<div class="loading-indicator"></div>
|
||||
<div class="image-load-error">
|
||||
<p>${vscode.l10n.t("An error occurred while loading the image.")}</p>
|
||||
<a href="#" class="open-file-link">${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")}</a>
|
||||
</div>
|
||||
<div class="git-lfs-info">
|
||||
<p>${vscode.l10n.t("The image is stored with Git LFS and is not available for preview.")}</p>
|
||||
<a href="#" class="open-file-link">${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")}</a>
|
||||
</div>
|
||||
<script src="${escapeAttribute(this.extensionResource('media', 'imagePreview.js'))}" nonce="${nonce}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private async getResourcePath(webviewEditor: vscode.WebviewPanel, resource: vscode.Uri, version: string): Promise<string | null> {
|
||||
if (await isGitLfsPointer(resource)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Avoid adding cache busting if there is already a query string
|
||||
if (resource.query) {
|
||||
return webviewEditor.webview.asWebviewUri(resource).toString();
|
||||
}
|
||||
return webviewEditor.webview.asWebviewUri(resource).with({ query: `version=${version}` }).toString();
|
||||
}
|
||||
|
||||
private extensionResource(...parts: string[]) {
|
||||
return this._webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts));
|
||||
}
|
||||
|
||||
public async reopenAsText() {
|
||||
await vscode.commands.executeCommand('reopenActiveEditorWith', 'default');
|
||||
this._webviewEditor.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function registerImagePreviewSupport(context: vscode.ExtensionContext, binarySizeStatusBarEntry: BinarySizeStatusBarEntry): vscode.Disposable {
|
||||
const disposables: vscode.Disposable[] = [];
|
||||
|
||||
const sizeStatusBarEntry = new SizeStatusBarEntry();
|
||||
disposables.push(sizeStatusBarEntry);
|
||||
|
||||
const zoomStatusBarEntry = new ZoomStatusBarEntry();
|
||||
disposables.push(zoomStatusBarEntry);
|
||||
|
||||
const previewManager = new ImagePreviewManager(context.extensionUri, sizeStatusBarEntry, binarySizeStatusBarEntry, zoomStatusBarEntry);
|
||||
|
||||
disposables.push(vscode.window.registerCustomEditorProvider(ImagePreviewManager.viewType, previewManager, {
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
}));
|
||||
|
||||
disposables.push(vscode.commands.registerCommand('imagePreview.zoomIn', () => {
|
||||
previewManager.activePreview?.zoomIn();
|
||||
}));
|
||||
|
||||
disposables.push(vscode.commands.registerCommand('imagePreview.zoomOut', () => {
|
||||
previewManager.activePreview?.zoomOut();
|
||||
}));
|
||||
|
||||
disposables.push(vscode.commands.registerCommand('imagePreview.copyImage', () => {
|
||||
previewManager.activePreview?.copyImage();
|
||||
}));
|
||||
|
||||
disposables.push(vscode.commands.registerCommand('imagePreview.reopenAsText', async () => {
|
||||
return previewManager.activePreview?.reopenAsText();
|
||||
}));
|
||||
|
||||
disposables.push(vscode.commands.registerCommand('imagePreview.reopenAsPreview', async () => {
|
||||
|
||||
await vscode.commands.executeCommand('reopenActiveEditorWith', ImagePreviewManager.viewType);
|
||||
}));
|
||||
|
||||
return vscode.Disposable.from(...disposables);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { PreviewStatusBarEntry } from '../ownedStatusBarEntry';
|
||||
|
||||
|
||||
export class SizeStatusBarEntry extends PreviewStatusBarEntry {
|
||||
|
||||
constructor() {
|
||||
super('status.imagePreview.size', vscode.l10n.t("Image Size"), vscode.StatusBarAlignment.Right, 101 /* to the left of editor status (100) */);
|
||||
}
|
||||
|
||||
public show(owner: unknown, text: string) {
|
||||
this.showItem(owner, text);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { PreviewStatusBarEntry as OwnedStatusBarEntry } from '../ownedStatusBarEntry';
|
||||
|
||||
|
||||
const selectZoomLevelCommandId = '_imagePreview.selectZoomLevel';
|
||||
|
||||
export type Scale = number | 'fit';
|
||||
|
||||
export class ZoomStatusBarEntry extends OwnedStatusBarEntry {
|
||||
|
||||
private readonly _onDidChangeScale = this._register(new vscode.EventEmitter<{ scale: Scale }>());
|
||||
public readonly onDidChangeScale = this._onDidChangeScale.event;
|
||||
|
||||
constructor() {
|
||||
super('status.imagePreview.zoom', vscode.l10n.t("Image Zoom"), vscode.StatusBarAlignment.Right, 102 /* to the left of editor size entry (101) */);
|
||||
|
||||
this._register(vscode.commands.registerCommand(selectZoomLevelCommandId, async () => {
|
||||
type MyPickItem = vscode.QuickPickItem & { scale: Scale };
|
||||
|
||||
const scales: Scale[] = [10, 5, 2, 1, 0.5, 0.2, 'fit'];
|
||||
const options = scales.map((scale): MyPickItem => ({
|
||||
label: this.zoomLabel(scale),
|
||||
scale
|
||||
}));
|
||||
|
||||
const pick = await vscode.window.showQuickPick(options, {
|
||||
placeHolder: vscode.l10n.t("Select zoom level")
|
||||
});
|
||||
if (pick) {
|
||||
this._onDidChangeScale.fire({ scale: pick.scale });
|
||||
}
|
||||
}));
|
||||
|
||||
this.entry.command = selectZoomLevelCommandId;
|
||||
}
|
||||
|
||||
public show(owner: unknown, scale: Scale) {
|
||||
this.showItem(owner, this.zoomLabel(scale));
|
||||
}
|
||||
|
||||
private zoomLabel(scale: Scale): string {
|
||||
return scale === 'fit'
|
||||
? vscode.l10n.t("Whole Image")
|
||||
: `${Math.round(scale * 100)}%`;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { Utils } from 'vscode-uri';
|
||||
import { BinarySizeStatusBarEntry } from './binarySizeStatusBarEntry';
|
||||
import { Disposable } from './util/dispose';
|
||||
|
||||
export async function reopenAsText(resource: vscode.Uri, viewColumn: vscode.ViewColumn | undefined): Promise<void> {
|
||||
await vscode.commands.executeCommand('vscode.openWith', resource, 'default', viewColumn);
|
||||
}
|
||||
|
||||
const gitLfsPointerPrefix = 'version https://git-lfs.github.com/spec/v1';
|
||||
|
||||
export async function isGitLfsPointer(resource: vscode.Uri): Promise<boolean> {
|
||||
if (resource.scheme !== 'git') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await vscode.workspace.fs.stat(resource);
|
||||
if (stat.size === 0 || stat.size > 1024) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await vscode.workspace.fs.readFile(resource);
|
||||
const text = new TextDecoder().decode(data);
|
||||
return text.startsWith(gitLfsPointerPrefix);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const enum PreviewState {
|
||||
Disposed,
|
||||
Visible,
|
||||
Active,
|
||||
}
|
||||
|
||||
export abstract class MediaPreview extends Disposable {
|
||||
|
||||
protected previewState = PreviewState.Visible;
|
||||
private _binarySize: number | undefined;
|
||||
|
||||
constructor(
|
||||
extensionRoot: vscode.Uri,
|
||||
protected readonly _resource: vscode.Uri,
|
||||
protected readonly _webviewEditor: vscode.WebviewPanel,
|
||||
private readonly _binarySizeStatusBarEntry: BinarySizeStatusBarEntry,
|
||||
) {
|
||||
super();
|
||||
|
||||
const resourceRoot = Utils.dirname(_resource).with({ query: '', fragment: '' });
|
||||
|
||||
_webviewEditor.webview.options = {
|
||||
enableScripts: true,
|
||||
enableForms: false,
|
||||
localResourceRoots: [
|
||||
resourceRoot,
|
||||
extensionRoot,
|
||||
]
|
||||
};
|
||||
|
||||
this._register(_webviewEditor.onDidChangeViewState(() => {
|
||||
this.updateState();
|
||||
}));
|
||||
|
||||
this._register(_webviewEditor.onDidDispose(() => {
|
||||
this.previewState = PreviewState.Disposed;
|
||||
this.dispose();
|
||||
}));
|
||||
|
||||
const watcher = this._register(vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(_resource, '*')));
|
||||
this._register(watcher.onDidChange(e => {
|
||||
if (e.toString() === this._resource.toString()) {
|
||||
this.updateBinarySize();
|
||||
this.render();
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(watcher.onDidDelete(e => {
|
||||
if (e.toString() === this._resource.toString()) {
|
||||
this._webviewEditor.dispose();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public override dispose() {
|
||||
super.dispose();
|
||||
this._binarySizeStatusBarEntry.hide(this);
|
||||
}
|
||||
|
||||
public get resource() {
|
||||
return this._resource;
|
||||
}
|
||||
|
||||
protected updateBinarySize() {
|
||||
vscode.workspace.fs.stat(this._resource).then(({ size }) => {
|
||||
this._binarySize = size;
|
||||
this.updateState();
|
||||
});
|
||||
}
|
||||
|
||||
protected async render() {
|
||||
if (this.previewState === PreviewState.Disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.getWebviewContents();
|
||||
if (this.previewState as PreviewState === PreviewState.Disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._webviewEditor.webview.html = content;
|
||||
}
|
||||
|
||||
protected abstract getWebviewContents(): Promise<string>;
|
||||
|
||||
protected updateState() {
|
||||
if (this.previewState === PreviewState.Disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._webviewEditor.active) {
|
||||
this.previewState = PreviewState.Active;
|
||||
this._binarySizeStatusBarEntry.show(this, this._binarySize);
|
||||
} else {
|
||||
this._binarySizeStatusBarEntry.hide(this);
|
||||
this.previewState = PreviewState.Visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { Disposable } from './util/dispose';
|
||||
|
||||
export abstract class PreviewStatusBarEntry extends Disposable {
|
||||
private _showOwner: unknown | undefined;
|
||||
|
||||
protected readonly entry: vscode.StatusBarItem;
|
||||
|
||||
constructor(id: string, name: string, alignment: vscode.StatusBarAlignment, priority: number) {
|
||||
super();
|
||||
this.entry = this._register(vscode.window.createStatusBarItem(id, alignment, priority));
|
||||
this.entry.name = name;
|
||||
}
|
||||
|
||||
protected showItem(owner: unknown, text: string) {
|
||||
this._showOwner = owner;
|
||||
this.entry.text = text;
|
||||
this.entry.show();
|
||||
}
|
||||
|
||||
public hide(owner: unknown) {
|
||||
if (owner === this._showOwner) {
|
||||
this.entry.hide();
|
||||
this._showOwner = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export function disposeAll(disposables: vscode.Disposable[]): void {
|
||||
while (disposables.length) {
|
||||
const item = disposables.pop();
|
||||
if (item) {
|
||||
item.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Disposable {
|
||||
private _isDisposed = false;
|
||||
|
||||
protected _disposables: vscode.Disposable[] = [];
|
||||
|
||||
public dispose(): void {
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._isDisposed = true;
|
||||
disposeAll(this._disposables);
|
||||
}
|
||||
|
||||
protected _register<T extends vscode.Disposable>(value: T): T {
|
||||
if (this._isDisposed) {
|
||||
value.dispose();
|
||||
} else {
|
||||
this._disposables.push(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
protected get isDisposed() {
|
||||
return this._isDisposed;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Copied from src/vs/base/common/uuid.ts
|
||||
*/
|
||||
export function generateUuid(): string {
|
||||
// use `randomUUID` if possible
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto
|
||||
// > Although crypto is available on all windows, the returned Crypto object only has one
|
||||
// > usable feature in insecure contexts: the getRandomValues() method.
|
||||
// > In general, you should use this API only in secure contexts.
|
||||
|
||||
return crypto.randomUUID.bind(crypto)();
|
||||
}
|
||||
|
||||
// prep-work
|
||||
const _data = new Uint8Array(16);
|
||||
const _hex: string[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
_hex.push(i.toString(16).padStart(2, '0'));
|
||||
}
|
||||
|
||||
// get data
|
||||
crypto.getRandomValues(_data);
|
||||
|
||||
// set version bits
|
||||
_data[6] = (_data[6] & 0x0f) | 0x40;
|
||||
_data[8] = (_data[8] & 0x3f) | 0x80;
|
||||
|
||||
// print as string
|
||||
let i = 0;
|
||||
let result = '';
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += '-';
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += '-';
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += '-';
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += '-';
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
result += _hex[_data[i++]];
|
||||
return result;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { BinarySizeStatusBarEntry } from './binarySizeStatusBarEntry';
|
||||
import { MediaPreview, isGitLfsPointer, reopenAsText } from './mediaPreview';
|
||||
import { escapeAttribute } from './util/dom';
|
||||
import { generateUuid } from './util/uuid';
|
||||
|
||||
|
||||
class VideoPreviewProvider implements vscode.CustomReadonlyEditorProvider {
|
||||
|
||||
public static readonly viewType = 'vscode.videoPreview';
|
||||
|
||||
constructor(
|
||||
private readonly extensionRoot: vscode.Uri,
|
||||
private readonly binarySizeStatusBarEntry: BinarySizeStatusBarEntry,
|
||||
) { }
|
||||
|
||||
public async openCustomDocument(uri: vscode.Uri) {
|
||||
return { uri, dispose: () => { } };
|
||||
}
|
||||
|
||||
public async resolveCustomEditor(document: vscode.CustomDocument, webviewEditor: vscode.WebviewPanel): Promise<void> {
|
||||
new VideoPreview(this.extensionRoot, document.uri, webviewEditor, this.binarySizeStatusBarEntry);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VideoPreview extends MediaPreview {
|
||||
|
||||
constructor(
|
||||
private readonly extensionRoot: vscode.Uri,
|
||||
resource: vscode.Uri,
|
||||
webviewEditor: vscode.WebviewPanel,
|
||||
binarySizeStatusBarEntry: BinarySizeStatusBarEntry,
|
||||
) {
|
||||
super(extensionRoot, resource, webviewEditor, binarySizeStatusBarEntry);
|
||||
|
||||
this._register(webviewEditor.webview.onDidReceiveMessage(message => {
|
||||
switch (message.type) {
|
||||
case 'reopen-as-text': {
|
||||
reopenAsText(resource, webviewEditor.viewColumn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this.updateBinarySize();
|
||||
this.render();
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
protected async getWebviewContents(): Promise<string> {
|
||||
const version = Date.now().toString();
|
||||
const configurations = vscode.workspace.getConfiguration('mediaPreview.video');
|
||||
const src = await this.getResourcePath(this._webviewEditor, this._resource, version);
|
||||
const settings = {
|
||||
src,
|
||||
isGitLfs: src === null,
|
||||
autoplay: configurations.get('autoPlay'),
|
||||
loop: configurations.get('loop'),
|
||||
};
|
||||
|
||||
const nonce = generateUuid();
|
||||
|
||||
const cspSource = this._webviewEditor.webview.cspSource;
|
||||
return /* html */`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<!-- Disable pinch zooming -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
|
||||
<title>Video Preview</title>
|
||||
|
||||
<link rel="stylesheet" href="${escapeAttribute(this.extensionResource('media', 'videoPreview.css'))}" type="text/css" media="screen" nonce="${nonce}">
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: ${cspSource}; media-src ${cspSource}; script-src 'nonce-${nonce}'; style-src ${cspSource} 'nonce-${nonce}';">
|
||||
<meta id="settings" data-settings="${escapeAttribute(JSON.stringify(settings))}">
|
||||
</head>
|
||||
<body class="loading" data-vscode-context='{ "preventDefaultContextMenuItems": true }'>
|
||||
<div class="loading-indicator"></div>
|
||||
<div class="loading-error">
|
||||
<p>${vscode.l10n.t("An error occurred while loading the video file.")}</p>
|
||||
<a href="#" class="open-file-link">${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")}</a>
|
||||
</div>
|
||||
<div class="git-lfs-info">
|
||||
<p>${vscode.l10n.t("The video file is stored with Git LFS and is not available for preview.")}</p>
|
||||
<a href="#" class="open-file-link">${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")}</a>
|
||||
</div>
|
||||
<script src="${escapeAttribute(this.extensionResource('media', 'videoPreview.js'))}" nonce="${nonce}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private async getResourcePath(webviewEditor: vscode.WebviewPanel, resource: vscode.Uri, version: string): Promise<string | null> {
|
||||
if (await isGitLfsPointer(resource)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Avoid adding cache busting if there is already a query string
|
||||
if (resource.query) {
|
||||
return webviewEditor.webview.asWebviewUri(resource).toString();
|
||||
}
|
||||
return webviewEditor.webview.asWebviewUri(resource).with({ query: `version=${version}` }).toString();
|
||||
}
|
||||
|
||||
private extensionResource(...parts: string[]) {
|
||||
return this._webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerVideoPreviewSupport(context: vscode.ExtensionContext, binarySizeStatusBarEntry: BinarySizeStatusBarEntry): vscode.Disposable {
|
||||
const provider = new VideoPreviewProvider(context.extensionUri, binarySizeStatusBarEntry);
|
||||
return vscode.window.registerCustomEditorProvider(VideoPreviewProvider.viewType, provider, {
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
webviewOptions: {
|
||||
retainContextWhenHidden: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./out",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"typeRoots": [
|
||||
"./node_modules/@types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../../src/vscode-dts/vscode.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -32,7 +32,7 @@ suite('vscode API - env', () => {
|
||||
test('env.remoteName', function () {
|
||||
const remoteName = env.remoteName;
|
||||
const knownWorkspaceExtension = extensions.getExtension('vscode.git');
|
||||
const knownUiAndWorkspaceExtension = extensions.getExtension('vscode.media-preview');
|
||||
const knownUiAndWorkspaceExtension = extensions.getExtension('vscode.github-authentication');
|
||||
if (typeof remoteName === 'undefined') {
|
||||
// not running in remote, so we expect both extensions
|
||||
assert.ok(knownWorkspaceExtension);
|
||||
|
||||
@@ -141,6 +141,11 @@ export namespace Schemas {
|
||||
*/
|
||||
export const vsls = 'vsls';
|
||||
|
||||
/**
|
||||
* Scheme used by the built-in git extension for resources at a specific revision
|
||||
*/
|
||||
export const git = 'git';
|
||||
|
||||
/**
|
||||
* Scheme used for the Source Control commit input's text document
|
||||
*/
|
||||
|
||||
@@ -73,6 +73,17 @@ export class BrowserClipboardService extends Disposable implements IClipboardSer
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
async writeImage(data: Uint8Array | Promise<Uint8Array>): Promise<void> {
|
||||
// Clear resources given we are writing an image
|
||||
this.clearResourcesState();
|
||||
|
||||
// Start the write synchronously and let the clipboard wait for the image data
|
||||
const image = Promise.resolve(data).then(bytes => new Blob([bytes as Uint8Array<ArrayBuffer>], { type: 'image/png' }));
|
||||
await getActiveWindow().navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': image })
|
||||
]);
|
||||
}
|
||||
|
||||
private webKitPendingClipboardWritePromise: DeferredPromise<string> | undefined;
|
||||
|
||||
// In Safari, it has the following note:
|
||||
|
||||
@@ -64,4 +64,12 @@ export interface IClipboardService {
|
||||
* image, an empty buffer is returned.
|
||||
*/
|
||||
readImage(): Promise<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Writes a PNG encoded image to the system clipboard.
|
||||
*
|
||||
* The image may be passed as a promise so that the write starts right away. Some browsers only
|
||||
* allow clipboard writes that start while handling the user gesture that triggered them.
|
||||
*/
|
||||
writeImage(data: Uint8Array | Promise<Uint8Array>): Promise<void>;
|
||||
}
|
||||
@@ -11,6 +11,19 @@ export class TestClipboardService implements IClipboardService {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
private image: Uint8Array | undefined = undefined;
|
||||
|
||||
async writeImage(data: Uint8Array | Promise<Uint8Array>): Promise<void> {
|
||||
this.image = await data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The image most recently written with {@link writeImage}.
|
||||
*/
|
||||
get writtenImage(): Uint8Array | undefined {
|
||||
return this.image;
|
||||
}
|
||||
|
||||
_serviceBrand: undefined;
|
||||
|
||||
private text: string | undefined = undefined;
|
||||
|
||||
@@ -400,6 +400,7 @@ export interface ICommonNativeHostService {
|
||||
readClipboardBuffer(format: string): Promise<VSBuffer>;
|
||||
hasClipboard(format: string, type?: 'selection' | 'clipboard'): Promise<boolean>;
|
||||
readImage(): Promise<Uint8Array>;
|
||||
writeImage(data: VSBuffer): Promise<void>;
|
||||
|
||||
// macOS Touchbar
|
||||
newWindowTab(): Promise<void>;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { exec } from 'child_process';
|
||||
import { app, BrowserWindow, clipboard, contentTracing, Display, Menu, MessageBoxOptions, MessageBoxReturnValue, Notification, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, powerMonitor, powerSaveBlocker, SaveDialogOptions, SaveDialogReturnValue, screen, shell, systemPreferences, webContents } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, contentTracing, Display, Menu, MessageBoxOptions, MessageBoxReturnValue, nativeImage, Notification, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, powerMonitor, powerSaveBlocker, SaveDialogOptions, SaveDialogReturnValue, screen, shell, systemPreferences, webContents } from 'electron';
|
||||
import { arch, cpus, freemem, loadavg, platform, release, totalmem, type } from 'os';
|
||||
import { promisify } from 'util';
|
||||
import { memoize } from '../../../base/common/decorators.js';
|
||||
@@ -1075,6 +1075,10 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
|
||||
return clipboard.readImage().toPNG();
|
||||
}
|
||||
|
||||
async writeImage(windowId: number | undefined, data: VSBuffer): Promise<void> {
|
||||
clipboard.writeImage(nativeImage.createFromBuffer(Buffer.from(data.buffer)));
|
||||
}
|
||||
|
||||
async writeClipboardText(windowId: number | undefined, text: string, type?: 'selection' | 'clipboard'): Promise<void> {
|
||||
return clipboard.writeText(text, type);
|
||||
}
|
||||
|
||||
@@ -319,6 +319,9 @@ import '../workbench/contrib/webviewPanel/browser/webviewPanel.contribution.js';
|
||||
import '../workbench/contrib/webviewView/browser/webviewView.contribution.js';
|
||||
import '../workbench/contrib/customEditor/browser/customEditor.contribution.js';
|
||||
|
||||
// Media Preview
|
||||
import '../workbench/contrib/mediaPreview/browser/mediaPreview.contribution.js';
|
||||
|
||||
// External Uri Opener
|
||||
import '../workbench/contrib/externalUriOpener/common/externalUriOpener.contribution.js';
|
||||
|
||||
|
||||
@@ -427,7 +427,10 @@ export class SideBySideEditor extends AbstractEditorWithViewState<ISideBySideEdi
|
||||
this.getLastFocusedEditorPane()?.focus();
|
||||
}
|
||||
|
||||
private getLastFocusedEditorPane(): EditorPane | undefined {
|
||||
/**
|
||||
* Gets the editor pane of the side that was focused last, or the primary side if neither was.
|
||||
*/
|
||||
getLastFocusedEditorPane(): EditorPane | undefined {
|
||||
if (this.lastFocusedSide === Side.SECONDARY) {
|
||||
return this.secondaryEditorPane;
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ Clicking an image attachment pill in chat (when `chat.imageCarousel.enabled` is
|
||||
- **Keyboard parity**: Uses `registerOpenEditorListeners` (click, double-click, Enter, Space) matching other attachment widgets.
|
||||
- **Arrow key navigation**: Handled via DOM `keydown` listener with `stopPropagation()` — not Action2 keybindings, because the modal editor's `KEY_DOWN` handler blocks `workbench.*` commands not in its allowlist. The editor overrides `focus()` to forward focus to the slideshow container so arrow keys work immediately without clicking.
|
||||
|
||||
### Zoom
|
||||
### Zoom and context menu
|
||||
|
||||
Zoom state is `ZoomScale = number | 'fit'` held in `_zoomScale`.
|
||||
Zooming and the image context menu come from the shared `ImageViewer` widget in `src/vs/workbench/contrib/mediaPreview/browser/imageViewer.ts`, which is also used by the built-in image preview editor. The carousel creates one viewer per `setInput()`, passes its slideshow container as the viewer's `focusTarget` (so arrow key navigation keeps working and modifier keys update the zoom cursor), and hides the viewer while a video is shown.
|
||||
|
||||
| Gesture | Effect |
|
||||
|---------|--------|
|
||||
@@ -57,13 +57,7 @@ Zoom state is `ZoomScale = number | 'fit'` held in `_zoomScale`.
|
||||
| Alt+click (Mac) / Ctrl+click (Win/Linux) | Zoom out one level |
|
||||
| Ctrl+scroll (Win/Linux) or Alt+scroll (Mac) | Continuous zoom (~7.5% per tick) |
|
||||
| Trackpad pinch | Zoom (reported as `wheel` + `e.ctrlKey`) |
|
||||
| Right-click | Context menu (`ImageViewerContextMenuId`) with **Copy** |
|
||||
| Copy (Cmd/Ctrl+C) | Copies the current image as PNG |
|
||||
|
||||
Predefined zoom levels: 10%, 20%, 30%, …, 100%, 150%, 200%, 300%, 500%, 700%, 1000%, 1500%, 2000%. Click cycles through these levels.
|
||||
|
||||
`_applyZoom(scale)` is the central method:
|
||||
- `'fit'` → adds `scale-to-fit` class, clears `img.style.zoom`, removes `.zoomed` from container
|
||||
- numeric → sets `img.style.zoom`, adds `.zoomed` on the container (enabling `overflow: auto` for panning with themed scrollbars), preserves scroll center using `dx/dy` ratio math, adds `pixelated` class at ≥ 3× zoom
|
||||
|
||||
Zoom always resets to `'fit'` when navigating to a different image.
|
||||
|
||||
Cursor changes to `zoom-out` when the zoom-out modifier key is held (via `.zoom-out` class on the container).
|
||||
`ImageViewer.setSource()` decodes the image before swapping it in and resets the zoom to `'fit'`, so zoom always resets when navigating to a different image.
|
||||
@@ -12,9 +12,7 @@ import { KeyCode } from '../../../../base/common/keyCodes.js';
|
||||
import { CancellationToken } from '../../../../base/common/cancellation.js';
|
||||
import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { getExtensionForMimeType } from '../../../../base/common/mime.js';
|
||||
import { clamp } from '../../../../base/common/numbers.js';
|
||||
import { extname } from '../../../../base/common/path.js';
|
||||
import { isMacintosh } from '../../../../base/common/platform.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
@@ -33,6 +31,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet
|
||||
import { IStorageService } from '../../../../platform/storage/common/storage.js';
|
||||
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
|
||||
import { IWebviewElement, IWebviewService } from '../../webview/browser/webview.js';
|
||||
import { IImageViewerEditorPane, ImageViewer } from '../../mediaPreview/browser/imageViewer.js';
|
||||
import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js';
|
||||
import { ICarouselImage, ICarouselSection, isVideoMimeType } from './imageCarouselTypes.js';
|
||||
|
||||
@@ -46,33 +45,31 @@ interface IFlatImageEntry {
|
||||
readonly image: ICarouselImage;
|
||||
}
|
||||
|
||||
type ZoomScale = number | 'fit';
|
||||
/**
|
||||
* The blob an image is shown from, and its object URL.
|
||||
*/
|
||||
interface ICachedImageBlob {
|
||||
readonly url: string;
|
||||
readonly blob: Blob;
|
||||
}
|
||||
|
||||
const SCALE_PINCH_FACTOR = 0.075;
|
||||
const MAX_SCALE = 20;
|
||||
const MIN_SCALE = 0.1;
|
||||
const PIXELATION_THRESHOLD = 3;
|
||||
const ZOOM_LEVELS = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.5, 2, 3, 5, 7, 10, 15, 20];
|
||||
|
||||
export class ImageCarouselEditor extends EditorPane {
|
||||
export class ImageCarouselEditor extends EditorPane implements IImageViewerEditorPane {
|
||||
static readonly ID = 'workbench.editor.imageCarousel';
|
||||
|
||||
private _container: HTMLElement | undefined;
|
||||
private _currentIndex: number = 0;
|
||||
private _zoomScale: ZoomScale = 'fit';
|
||||
private _viewer: ImageViewer | undefined;
|
||||
private _sections: ReadonlyArray<ICarouselSection> = [];
|
||||
private _flatImages: IFlatImageEntry[] = [];
|
||||
private readonly _contentDisposables = this._register(new DisposableStore());
|
||||
private readonly _imageDisposables = this._register(new DisposableStore());
|
||||
private readonly _blobUrlCache = new Map<string, string>();
|
||||
private readonly _blobUrlCache = new Map<string, ICachedImageBlob>();
|
||||
private _blobUrlCacheGeneration = 0;
|
||||
|
||||
private _videoWebview: IWebviewElement | undefined;
|
||||
private _elements: {
|
||||
root: HTMLElement;
|
||||
imageArea: HTMLElement;
|
||||
mainImageContainer: HTMLElement;
|
||||
mainImage: HTMLImageElement;
|
||||
videoContainer: HTMLElement;
|
||||
captionText: HTMLElement;
|
||||
captionSeparator: HTMLElement;
|
||||
@@ -99,6 +96,10 @@ export class ImageCarouselEditor extends EditorPane {
|
||||
super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService);
|
||||
}
|
||||
|
||||
get imageViewer(): ImageViewer | undefined {
|
||||
return this._isCurrentVideo() ? undefined : this._viewer;
|
||||
}
|
||||
|
||||
protected override createEditor(parent: HTMLElement): void {
|
||||
this._container = h('div.image-carousel-editor').root;
|
||||
parent.appendChild(this._container);
|
||||
@@ -124,7 +125,7 @@ export class ImageCarouselEditor extends EditorPane {
|
||||
this._contentDisposables.clear();
|
||||
this._imageDisposables.clear();
|
||||
this._revokeCachedBlobUrls();
|
||||
this._zoomScale = 'fit';
|
||||
this._viewer = undefined;
|
||||
if (this._container) {
|
||||
clearNode(this._container);
|
||||
}
|
||||
@@ -149,6 +150,7 @@ export class ImageCarouselEditor extends EditorPane {
|
||||
this._contentDisposables.clear();
|
||||
this._imageDisposables.clear();
|
||||
this._revokeCachedBlobUrls();
|
||||
this._viewer = undefined;
|
||||
clearNode(this._container);
|
||||
|
||||
if (this._flatImages.length === 0) {
|
||||
@@ -160,10 +162,7 @@ export class ImageCarouselEditor extends EditorPane {
|
||||
|
||||
const elements = h('div.slideshow-container', [
|
||||
h('div.image-area@imageArea', [
|
||||
h('div.main-image-container@mainImageContainer', [
|
||||
h('img.main-image@mainImage'),
|
||||
h('div.video-container@videoContainer'),
|
||||
]),
|
||||
h('div.video-container@videoContainer'),
|
||||
h('button.nav-arrow.prev-arrow@prevBtn', { ariaLabel: localize('imageCarousel.previousImage', "Previous image") }, [
|
||||
h('span.codicon.codicon-chevron-left', { ariaHidden: 'true' }),
|
||||
]),
|
||||
@@ -207,8 +206,6 @@ export class ImageCarouselEditor extends EditorPane {
|
||||
this._elements = {
|
||||
root: elements.root,
|
||||
imageArea: elements.imageArea,
|
||||
mainImageContainer: elements.mainImageContainer,
|
||||
mainImage: elements.mainImage as HTMLImageElement,
|
||||
videoContainer: elements.videoContainer,
|
||||
captionText: elements.captionText,
|
||||
captionSeparator: elements.captionSeparator,
|
||||
@@ -219,9 +216,10 @@ export class ImageCarouselEditor extends EditorPane {
|
||||
sectionsContainer: elements.sectionsContainer,
|
||||
};
|
||||
|
||||
// Initialize image in fit mode
|
||||
this._elements.mainImage.classList.add('scale-to-fit');
|
||||
this._elements.mainImage.alt = '';
|
||||
// The viewer handles zooming and the image context menu. Keyboard focus stays on the
|
||||
// slideshow so that arrow key navigation keeps working.
|
||||
this._viewer = this._contentDisposables.add(this._instantiationService.createInstance(ImageViewer, { focusTarget: elements.root }));
|
||||
this._elements.imageArea.prepend(this._viewer.element);
|
||||
|
||||
// Hide video container initially
|
||||
this._elements.videoContainer.style.display = 'none';
|
||||
@@ -255,60 +253,6 @@ export class ImageCarouselEditor extends EditorPane {
|
||||
}));
|
||||
elements.root.tabIndex = 0;
|
||||
|
||||
// Zoom: scroll wheel + modifier key (Ctrl on Win/Linux, Alt on Mac) or pinch
|
||||
this._contentDisposables.add(addDisposableListener(this._elements.imageArea, EventType.MOUSE_WHEEL, (e: WheelEvent) => {
|
||||
if (this._isCurrentVideo()) {
|
||||
return;
|
||||
}
|
||||
const isZoomModifier = isMacintosh ? e.altKey : e.ctrlKey;
|
||||
if (!isZoomModifier && !e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
if (e.deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._zoomScale === 'fit') {
|
||||
this._initZoomFromFit();
|
||||
}
|
||||
|
||||
const delta = e.deltaY > 0 ? 1 : -1;
|
||||
this._applyZoom((this._zoomScale as number) * (1 - delta * SCALE_PINCH_FACTOR));
|
||||
}, { passive: false }));
|
||||
|
||||
// Zoom: single click to zoom in/out (like image preview)
|
||||
// Track modifier keys at mousedown time
|
||||
let clickCtrlPressed = false;
|
||||
let clickAltPressed = false;
|
||||
this._contentDisposables.add(addDisposableListener(this._elements.mainImageContainer, EventType.MOUSE_DOWN, (e: MouseEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
clickCtrlPressed = e.ctrlKey;
|
||||
clickAltPressed = e.altKey;
|
||||
}));
|
||||
this._contentDisposables.add(addDisposableListener(this._elements.mainImageContainer, EventType.CLICK, (e: MouseEvent) => {
|
||||
if (e.button !== 0 || this._isCurrentVideo()) {
|
||||
return;
|
||||
}
|
||||
const isZoomOut = isMacintosh ? clickAltPressed : clickCtrlPressed;
|
||||
if (isZoomOut) {
|
||||
this._zoomOut();
|
||||
} else {
|
||||
this._zoomIn();
|
||||
}
|
||||
}));
|
||||
|
||||
// Update zoom-out cursor class when modifier key is held
|
||||
const updateZoomCursor = (e: KeyboardEvent) => {
|
||||
const isZoomOut = isMacintosh ? e.altKey : e.ctrlKey;
|
||||
this._elements!.mainImageContainer.classList.toggle('zoom-out', isZoomOut);
|
||||
};
|
||||
this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_DOWN, updateZoomCursor));
|
||||
this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_UP, updateZoomCursor));
|
||||
|
||||
// Build section thumbnails
|
||||
this._thumbnailElements = [];
|
||||
let flatIndex = 0;
|
||||
@@ -430,10 +374,11 @@ export class ImageCarouselEditor extends EditorPane {
|
||||
|
||||
if (isVideo) {
|
||||
// Show video container, hide image
|
||||
this._elements.mainImage.style.display = 'none';
|
||||
this._viewer?.clear();
|
||||
if (this._viewer) {
|
||||
this._viewer.element.style.display = 'none';
|
||||
}
|
||||
this._elements.videoContainer.style.display = '';
|
||||
this._elements.mainImageContainer.classList.remove('zoomed');
|
||||
this._elements.mainImageContainer.style.cursor = 'default';
|
||||
|
||||
// Load raw data to send via postMessage
|
||||
const rawData = await this._loadRawData(currentImage);
|
||||
@@ -478,36 +423,28 @@ window.addEventListener("message",function(e){var m=e.data;if(m.type==="loadVide
|
||||
} else {
|
||||
// Show image, hide video container
|
||||
this._elements.videoContainer.style.display = 'none';
|
||||
this._elements.mainImage.style.display = '';
|
||||
this._elements.mainImageContainer.style.cursor = '';
|
||||
if (this._viewer) {
|
||||
this._viewer.element.style.display = '';
|
||||
// An image that is still decoding from an earlier navigation must not replace this one
|
||||
this._viewer.cancelPendingSource();
|
||||
}
|
||||
|
||||
const url = await this._loadBlobUrl(currentImage);
|
||||
const cached = await this._loadBlob(currentImage);
|
||||
|
||||
// If the user navigated while loading the blob URL, discard this result.
|
||||
if (this._currentIndex !== navigationIndex || this._blobUrlCacheGeneration !== blobUrlCacheGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmp = new Image();
|
||||
tmp.src = url;
|
||||
tmp.decode().then(() => {
|
||||
// Only apply if user hasn't navigated away during decode
|
||||
if (this._currentIndex === navigationIndex && this._blobUrlCacheGeneration === blobUrlCacheGeneration && this._elements) {
|
||||
this._elements.mainImage.src = url;
|
||||
this._elements.mainImage.alt = currentImage.name;
|
||||
}
|
||||
}, () => {
|
||||
// Decode failed (invalid image) — still show src for browser fallback
|
||||
if (this._currentIndex === navigationIndex && this._blobUrlCacheGeneration === blobUrlCacheGeneration && this._elements) {
|
||||
this._elements.mainImage.src = url;
|
||||
this._elements.mainImage.alt = currentImage.name;
|
||||
}
|
||||
// The viewer decodes the image before showing it and resets the zoom. Invalid images
|
||||
// are still shown so that the browser renders its broken image fallback. Copying reads
|
||||
// the displayed blob, which stays the same even if the file changes afterwards.
|
||||
void this._viewer?.setSource(cached?.url ?? '', {
|
||||
alt: currentImage.name,
|
||||
data: cached && { mimeType: currentImage.mimeType, read: async () => new Uint8Array(await cached.blob.arrayBuffer()) },
|
||||
});
|
||||
}
|
||||
|
||||
// Reset zoom when switching images
|
||||
this._applyZoom('fit');
|
||||
|
||||
// Update info bar: caption + separator + counter
|
||||
if (currentImage.caption) {
|
||||
this._elements.captionText.textContent = currentImage.caption;
|
||||
@@ -566,6 +503,10 @@ window.addEventListener("message",function(e){var m=e.data;if(m.type==="loadVide
|
||||
}
|
||||
|
||||
private async _loadBlobUrl(image: ICarouselImage): Promise<string> {
|
||||
return (await this._loadBlob(image))?.url ?? '';
|
||||
}
|
||||
|
||||
private async _loadBlob(image: ICarouselImage): Promise<ICachedImageBlob | undefined> {
|
||||
const cached = this._blobUrlCache.get(image.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
@@ -580,21 +521,27 @@ window.addEventListener("message",function(e){var m=e.data;if(m.type==="loadVide
|
||||
const content = await this._fileService.readFile(image.uri);
|
||||
buffer = content.value.buffer;
|
||||
} else {
|
||||
return '';
|
||||
return undefined;
|
||||
}
|
||||
if (this._blobUrlCacheGeneration !== blobUrlCacheGeneration) {
|
||||
return '';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Reuse the blob of a concurrent load that finished first
|
||||
const concurrentlyLoaded = this._blobUrlCache.get(image.id);
|
||||
if (concurrentlyLoaded) {
|
||||
return concurrentlyLoaded;
|
||||
}
|
||||
|
||||
const blob = new Blob([buffer as Uint8Array<ArrayBuffer>], { type: image.mimeType });
|
||||
const url = this._blobUrlCache.get(image.id) ?? URL.createObjectURL(blob);
|
||||
this._blobUrlCache.set(image.id, url);
|
||||
return url;
|
||||
const loaded = { url: URL.createObjectURL(blob), blob };
|
||||
this._blobUrlCache.set(image.id, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private _revokeCachedBlobUrls(): void {
|
||||
this._blobUrlCacheGeneration++;
|
||||
for (const url of this._blobUrlCache.values()) {
|
||||
for (const { url } of this._blobUrlCache.values()) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
this._blobUrlCache.clear();
|
||||
@@ -658,104 +605,6 @@ window.addEventListener("message",function(e){var m=e.data;if(m.type==="loadVide
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the current display scale when transitioning from 'fit' to numeric zoom.
|
||||
*/
|
||||
private _initZoomFromFit(): void {
|
||||
if (!this._elements) {
|
||||
return;
|
||||
}
|
||||
const img = this._elements.mainImage;
|
||||
if (img.naturalWidth > 0) {
|
||||
this._zoomScale = img.clientWidth / img.naturalWidth;
|
||||
} else {
|
||||
this._zoomScale = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom in to the next predefined zoom level.
|
||||
*/
|
||||
private _zoomIn(): void {
|
||||
if (this._zoomScale === 'fit') {
|
||||
this._initZoomFromFit();
|
||||
}
|
||||
const scale = this._zoomScale as number;
|
||||
let i = 0;
|
||||
for (; i < ZOOM_LEVELS.length; ++i) {
|
||||
if (ZOOM_LEVELS[i] > scale) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._applyZoom(ZOOM_LEVELS[i] ?? MAX_SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom out to the previous predefined zoom level.
|
||||
*/
|
||||
private _zoomOut(): void {
|
||||
if (this._zoomScale === 'fit') {
|
||||
this._initZoomFromFit();
|
||||
}
|
||||
const scale = this._zoomScale as number;
|
||||
let i = ZOOM_LEVELS.length - 1;
|
||||
for (; i >= 0; --i) {
|
||||
if (ZOOM_LEVELS[i] < scale) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._applyZoom(ZOOM_LEVELS[i] ?? MIN_SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply fit-to-container or numeric zoom with scroll-center preservation.
|
||||
*/
|
||||
private _applyZoom(newScale: ZoomScale): void {
|
||||
if (!this._elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this._elements.mainImageContainer;
|
||||
const img = this._elements.mainImage;
|
||||
|
||||
if (newScale === 'fit') {
|
||||
this._zoomScale = 'fit';
|
||||
img.classList.add('scale-to-fit');
|
||||
img.classList.remove('pixelated');
|
||||
img.style.zoom = '';
|
||||
// Remove zoomed/overflow before scrollTo to avoid an expensive
|
||||
// synchronous ScrollLayer that blocks the main thread.
|
||||
const wasZoomed = container.classList.contains('zoomed');
|
||||
container.classList.remove('zoomed');
|
||||
container.classList.remove('zoom-out');
|
||||
if (wasZoomed) {
|
||||
container.scrollTo(0, 0);
|
||||
}
|
||||
} else {
|
||||
const scale = clamp(newScale, MIN_SCALE, MAX_SCALE);
|
||||
this._zoomScale = scale;
|
||||
|
||||
// Capture scroll center ratio before changing zoom.
|
||||
const dx = container.scrollWidth > 0
|
||||
? (container.scrollLeft + container.clientWidth / 2) / container.scrollWidth
|
||||
: 0.5;
|
||||
const dy = container.scrollHeight > 0
|
||||
? (container.scrollTop + container.clientHeight / 2) / container.scrollHeight
|
||||
: 0.5;
|
||||
|
||||
img.classList.remove('scale-to-fit');
|
||||
img.classList.toggle('pixelated', scale >= PIXELATION_THRESHOLD);
|
||||
img.style.zoom = String(scale);
|
||||
container.classList.add('zoomed');
|
||||
|
||||
// Restore scroll center — works because setting img.style.zoom triggers
|
||||
// synchronous layout, so scrollWidth/scrollHeight reflect the new size.
|
||||
const newScrollX = container.scrollWidth * dx - container.clientWidth / 2;
|
||||
const newScrollY = container.scrollHeight * dy - container.clientHeight / 2;
|
||||
container.scrollTo(newScrollX, newScrollY);
|
||||
}
|
||||
}
|
||||
|
||||
override focus(): void {
|
||||
super.focus();
|
||||
this._elements?.root.focus();
|
||||
|
||||
@@ -44,44 +44,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image-container.zoom-out {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image-container.zoomed {
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
scrollbar-color: var(--vscode-scrollbarSlider-background) transparent;
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image-container.zoomed::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image-container.zoomed::-webkit-scrollbar-thumb {
|
||||
background-color: var(--vscode-scrollbarSlider-background);
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image-container.zoomed::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--vscode-scrollbarSlider-hoverBackground);
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image-container.zoomed::-webkit-scrollbar-thumb:active {
|
||||
background-color: var(--vscode-scrollbarSlider-activeBackground);
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image {
|
||||
border-radius: 4px;
|
||||
margin: auto;
|
||||
.image-carousel-editor .image-viewer img {
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
}
|
||||
|
||||
.image-carousel-editor .video-container {
|
||||
@@ -91,16 +55,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image.scale-to-fit {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-carousel-editor .main-image.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Overlay navigation arrows */
|
||||
.image-carousel-editor .nav-arrow {
|
||||
position: absolute;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ImageCarouselEditor } from '../../browser/imageCarouselEditor.js';
|
||||
import { ICarouselImage } from '../../browser/imageCarouselTypes.js';
|
||||
|
||||
interface ImageCarouselEditorTestAccessor {
|
||||
_blobUrlCache: Map<string, string>;
|
||||
_blobUrlCache: Map<string, { readonly url: string; readonly blob: Blob }>;
|
||||
_blobUrlCacheGeneration: number;
|
||||
_loadBlobUrl(image: ICarouselImage): Promise<string>;
|
||||
_revokeCachedBlobUrls(): void;
|
||||
@@ -185,15 +185,18 @@ suite('ImageCarouselEditor', () => {
|
||||
await currentReadFileResult.complete({ resource, value: VSBuffer.fromString('<svg>current</svg>') } as IFileContent);
|
||||
await staleReadFileResult.complete({ resource, value: VSBuffer.fromString('<svg>stale</svg>') } as IFileContent);
|
||||
|
||||
const cached = [...editor._blobUrlCache.values()];
|
||||
assert.deepStrictEqual({
|
||||
staleUrl: await staleUrl,
|
||||
currentUrl: await currentUrl,
|
||||
cachedUrls: [...editor._blobUrlCache.values()],
|
||||
cachedUrls: cached.map(entry => entry.url),
|
||||
cachedContents: await Promise.all(cached.map(entry => entry.blob.text())),
|
||||
createObjectURLCallCount: createObjectURL.callCount,
|
||||
}, {
|
||||
staleUrl: '',
|
||||
currentUrl: 'blob:current',
|
||||
cachedUrls: ['blob:current'],
|
||||
cachedContents: ['<svg>current</svg>'],
|
||||
createObjectURLCallCount: 1,
|
||||
});
|
||||
editor._revokeCachedBlobUrls();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { SideBySideEditor } from '../../../browser/parts/editor/sideBySideEditor.js';
|
||||
import { IWorkbenchContribution } from '../../../common/contributions.js';
|
||||
import { IEditorPane } from '../../../common/editor.js';
|
||||
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
||||
import { ImagePreviewEditor } from './imagePreviewEditor.js';
|
||||
|
||||
/**
|
||||
* Whether the active editor is an image preview, or for diffs, whether the side that was focused
|
||||
* last is an image preview.
|
||||
*/
|
||||
export const ActiveImagePreviewContext = new RawContextKey<boolean>('activeImagePreview', false, localize('activeImagePreview', "Whether the active editor, or the focused side of the active diff editor, is an image preview"));
|
||||
|
||||
/**
|
||||
* Gets the editor pane the user works with: the active editor pane, or for side by side editors
|
||||
* such as diffs, the side that was focused last.
|
||||
*/
|
||||
export function getActiveInnerEditorPane(editorService: IEditorService): IEditorPane | undefined {
|
||||
const pane = editorService.activeEditorPane;
|
||||
return pane instanceof SideBySideEditor ? pane.getLastFocusedEditorPane() : pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` with the {@link getActiveInnerEditorPane active inner editor pane} now and
|
||||
* whenever it changes, including when focus moves to the other side of a side by side editor.
|
||||
*/
|
||||
export function onDidChangeActiveInnerEditorPane(editorService: IEditorService, callback: (pane: IEditorPane | undefined) => void): IDisposable {
|
||||
const store = new DisposableStore();
|
||||
const sideListener = store.add(new MutableDisposable());
|
||||
const update = () => callback(getActiveInnerEditorPane(editorService));
|
||||
const onDidActiveEditorChange = () => {
|
||||
const pane = editorService.activeEditorPane;
|
||||
sideListener.value = pane instanceof SideBySideEditor ? pane.onDidChangeControl(update) : undefined;
|
||||
update();
|
||||
};
|
||||
store.add(editorService.onDidActiveEditorChange(onDidActiveEditorChange));
|
||||
onDidActiveEditorChange();
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps {@link ActiveImagePreviewContext} up to date.
|
||||
*/
|
||||
export class ActiveImagePreviewContextKeyContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
static readonly ID = 'workbench.contrib.activeImagePreviewContextKey';
|
||||
|
||||
constructor(
|
||||
@IEditorService editorService: IEditorService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
) {
|
||||
super();
|
||||
|
||||
const activeImagePreview = ActiveImagePreviewContext.bindTo(contextKeyService);
|
||||
this._register(onDidChangeActiveInnerEditorPane(editorService, pane => activeImagePreview.set(pane instanceof ImagePreviewEditor)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IWebWorkerServerRequestHandler } from '../../../../base/common/worker/webWorker.js';
|
||||
|
||||
/**
|
||||
* Encodes images to PNG off the main thread. On the main thread, Chromium encodes canvases using
|
||||
* idle tasks, which can be starved for seconds when the renderer is busy.
|
||||
*/
|
||||
export class ImageEncoderWorker implements IWebWorkerServerRequestHandler {
|
||||
_requestHandlerBrand: void = undefined;
|
||||
|
||||
async $encodePng(bitmap: ImageBitmap): Promise<ArrayBuffer> {
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Failed to create a canvas to encode the image');
|
||||
}
|
||||
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
// Free the source pixels before encoding so only one full-size copy is alive during the encode
|
||||
bitmap.close();
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
return await blob.arrayBuffer();
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function create(): ImageEncoderWorker {
|
||||
return new ImageEncoderWorker();
|
||||
}
|
||||
+4
-4
@@ -2,8 +2,8 @@
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export function escapeAttribute(value: string | vscode.Uri): string {
|
||||
return value.toString().replace(/"/g, '"');
|
||||
}
|
||||
import { create } from './imageEncoderWorker.js';
|
||||
import { bootstrapWebWorker } from '../../../../base/common/worker/webWorkerBootstrap.js';
|
||||
|
||||
bootstrapWebWorker(create);
|
||||
@@ -0,0 +1,284 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './media/imagePreviewEditor.css';
|
||||
import { $, append, Dimension } from '../../../../base/browser/dom.js';
|
||||
import { Button } from '../../../../base/browser/ui/button/button.js';
|
||||
import { RunOnceScheduler } from '../../../../base/common/async.js';
|
||||
import { CancellationToken } from '../../../../base/common/cancellation.js';
|
||||
import { Emitter } from '../../../../base/common/event.js';
|
||||
import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { getMediaMime } from '../../../../base/common/mime.js';
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
|
||||
import { FileChangesEvent, FileChangeType, IFileContent, IFileService } from '../../../../platform/files/common/files.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IStorageService } from '../../../../platform/storage/common/storage.js';
|
||||
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
|
||||
import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js';
|
||||
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
|
||||
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
|
||||
import { IEditorMemento, IEditorOpenContext } from '../../../common/editor.js';
|
||||
import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';
|
||||
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
||||
import { isGitLfsPointer } from '../common/mediaPreview.js';
|
||||
import { IImageViewerEditorPane, IImageViewerViewState, ImageViewer } from './imageViewer.js';
|
||||
import { ImagePreviewEditorInput } from './imagePreviewEditorInput.js';
|
||||
import { reopenInTextEditor } from './mediaPreviewErrors.js';
|
||||
|
||||
function isImageViewerViewState(value: unknown): value is IImageViewerViewState {
|
||||
const candidate = value as IImageViewerViewState | undefined;
|
||||
return !!candidate
|
||||
&& (candidate.scale === 'fit' || typeof candidate.scale === 'number')
|
||||
&& typeof candidate.scrollLeft === 'number'
|
||||
&& typeof candidate.scrollTop === 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor pane that previews image files.
|
||||
*/
|
||||
export class ImagePreviewEditor extends EditorPane implements IImageViewerEditorPane {
|
||||
|
||||
static readonly ID = 'workbench.editor.imagePreview';
|
||||
|
||||
private _container: HTMLElement | undefined;
|
||||
private _viewer: ImageViewer | undefined;
|
||||
private _message: { readonly label: HTMLElement; readonly button: Button } | undefined;
|
||||
private _hasMessage = false;
|
||||
private _fileSize: number | undefined;
|
||||
private _loadId = 0;
|
||||
|
||||
private readonly _objectUrl = this._register(new MutableDisposable());
|
||||
private readonly _inputDisposables = this._register(new DisposableStore());
|
||||
private readonly _viewStateMemento: IEditorMemento<IImageViewerViewState>;
|
||||
|
||||
private readonly _onDidChangeImageInfo = this._register(new Emitter<void>());
|
||||
/**
|
||||
* Fires when the zoom, dimensions or file size of the shown image change.
|
||||
*/
|
||||
readonly onDidChangeImageInfo = this._onDidChangeImageInfo.event;
|
||||
|
||||
constructor(
|
||||
group: IEditorGroup,
|
||||
@ITelemetryService telemetryService: ITelemetryService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@IStorageService storageService: IStorageService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
@IEditorGroupsService editorGroupsService: IEditorGroupsService,
|
||||
@ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService,
|
||||
) {
|
||||
super(ImagePreviewEditor.ID, group, telemetryService, themeService, storageService);
|
||||
|
||||
this._viewStateMemento = this.getEditorMemento<IImageViewerViewState>(editorGroupsService, textResourceConfigurationService, 'imagePreviewViewState');
|
||||
}
|
||||
|
||||
get imageViewer(): ImageViewer | undefined {
|
||||
return this._viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Size of the image file in bytes.
|
||||
*/
|
||||
get fileSize(): number | undefined {
|
||||
return this._fileSize;
|
||||
}
|
||||
|
||||
protected override createEditor(parent: HTMLElement): void {
|
||||
this._container = $('.image-preview-editor');
|
||||
parent.appendChild(this._container);
|
||||
|
||||
const viewer = this._viewer = this._register(this._instantiationService.createInstance(ImageViewer, {}));
|
||||
viewer.element.tabIndex = 0;
|
||||
viewer.element.setAttribute('role', 'img');
|
||||
this._container.appendChild(viewer.element);
|
||||
|
||||
this._register(viewer.onDidChangeScale(() => this._onDidChangeImageInfo.fire()));
|
||||
|
||||
// Shown instead of the image when it can't be previewed. It is part of the pane rather
|
||||
// than an editor error, so the other side of a diff stays visible.
|
||||
const message = append(this._container, $('.monaco-editor-pane-placeholder.image-preview-message'));
|
||||
const label = append(message, $('.editor-placeholder-label-container'));
|
||||
label.id = generateUuid();
|
||||
const button = this._register(new Button(append(message, $('.editor-placeholder-buttons-container')), defaultButtonStyles));
|
||||
button.label = localize('mediaPreview.openInTextEditor', "Open in Text Editor");
|
||||
button.element.setAttribute('aria-describedby', label.id);
|
||||
this._register(button.onDidClick(() => {
|
||||
if (this.input) {
|
||||
void reopenInTextEditor(this.input, this._editorService);
|
||||
}
|
||||
}));
|
||||
this._message = { label, button };
|
||||
}
|
||||
|
||||
override async setInput(input: ImagePreviewEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
|
||||
this._saveViewState();
|
||||
this._inputDisposables.clear();
|
||||
this._setMessage(undefined);
|
||||
|
||||
await super.setInput(input, options, context, token);
|
||||
if (token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._viewer?.element.setAttribute('aria-label', localize('imagePreview.ariaLabel', "Image Preview: {0}", input.getName()));
|
||||
|
||||
const viewState = isImageViewerViewState(options?.viewState) ? options.viewState : this._viewStateMemento.loadEditorState(this.group, input);
|
||||
|
||||
// Watch before loading so changes made while the image loads aren't missed. A reload
|
||||
// supersedes a load that is still in progress.
|
||||
const reloadScheduler = this._inputDisposables.add(new RunOnceScheduler(() => this._reloadImage(input, viewState), 100));
|
||||
const onDidFilesChange = (e: FileChangesEvent) => {
|
||||
if (e.contains(input.resource, FileChangeType.UPDATED, FileChangeType.ADDED)) {
|
||||
reloadScheduler.schedule();
|
||||
}
|
||||
};
|
||||
const watcher = this._inputDisposables.add(this._fileService.createWatcher(input.resource, { recursive: false, excludes: [] }));
|
||||
this._inputDisposables.add(watcher.onDidChange(onDidFilesChange));
|
||||
// File systems contributed by extensions only report uncorrelated changes
|
||||
this._inputDisposables.add(this._fileService.onDidFilesChange(onDidFilesChange));
|
||||
|
||||
await this._loadImage(input, viewState, token);
|
||||
}
|
||||
|
||||
override clearInput(): void {
|
||||
this._saveViewState();
|
||||
this._inputDisposables.clear();
|
||||
this._loadId++;
|
||||
this._viewer?.clear();
|
||||
this._setMessage(undefined);
|
||||
this._objectUrl.clear();
|
||||
this._fileSize = undefined;
|
||||
this._onDidChangeImageInfo.fire();
|
||||
|
||||
super.clearInput();
|
||||
}
|
||||
|
||||
override getViewState(): IImageViewerViewState | undefined {
|
||||
return this._viewer?.hasImage ? this._viewer.getViewState() : undefined;
|
||||
}
|
||||
|
||||
override focus(): void {
|
||||
super.focus();
|
||||
if (this._hasMessage) {
|
||||
this._message?.button.focus();
|
||||
} else {
|
||||
this._viewer?.element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
override layout(dimension: Dimension): void {
|
||||
if (this._container) {
|
||||
this._container.style.width = `${dimension.width}px`;
|
||||
this._container.style.height = `${dimension.height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
protected override saveState(): void {
|
||||
this._saveViewState();
|
||||
super.saveState();
|
||||
}
|
||||
|
||||
private _saveViewState(): void {
|
||||
const viewState = this.getViewState();
|
||||
if (this.input instanceof ImagePreviewEditorInput && viewState) {
|
||||
this._viewStateMemento.saveEditorState(this.group, this.input, viewState);
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadImage(input: ImagePreviewEditorInput, viewState: IImageViewerViewState | undefined, token: CancellationToken): Promise<void> {
|
||||
// Claim the load before any async work so that older loads and reloads can't win
|
||||
const loadId = ++this._loadId;
|
||||
|
||||
if (await isGitLfsPointer(this._fileService, input.resource)) {
|
||||
if (this._isCurrentLoad(loadId, input) && !token.isCancellationRequested) {
|
||||
this._viewer?.clear();
|
||||
this._objectUrl.clear();
|
||||
this._fileSize = undefined;
|
||||
this._setMessage(localize('imagePreview.gitLfs', "The image is stored with Git LFS and is not available for preview."));
|
||||
this._onDidChangeImageInfo.fire();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this._fileService.readFile(input.resource, undefined, token);
|
||||
if (token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._showImage(loadId, input, content, viewState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param initialViewState View state to apply if the image wasn't shown yet.
|
||||
*/
|
||||
private async _reloadImage(input: ImagePreviewEditorInput, initialViewState: IImageViewerViewState | undefined): Promise<void> {
|
||||
const viewer = this._viewer;
|
||||
if (!viewer || this.input !== input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadId = ++this._loadId;
|
||||
const viewState = viewer.hasImage ? viewer.getViewState() : initialViewState;
|
||||
|
||||
let content: IFileContent;
|
||||
try {
|
||||
content = await this._fileService.readFile(input.resource);
|
||||
} catch {
|
||||
// Keep showing the previous image, e.g. when the file is in the middle of being written
|
||||
return;
|
||||
}
|
||||
|
||||
await this._showImage(loadId, input, content, viewState);
|
||||
}
|
||||
|
||||
private _isCurrentLoad(loadId: number, input: ImagePreviewEditorInput): boolean {
|
||||
return loadId === this._loadId && this.input === input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the image if the load is still current, or an error message if it can't be decoded.
|
||||
*/
|
||||
private async _showImage(loadId: number, input: ImagePreviewEditorInput, content: IFileContent, viewState: IImageViewerViewState | undefined): Promise<void> {
|
||||
const viewer = this._viewer;
|
||||
if (!viewer || !this._isCurrentLoad(loadId, input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = getMediaMime(input.resource.path) ?? '';
|
||||
const blob = new Blob([content.value.buffer as Uint8Array<ArrayBuffer>], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const loaded = await viewer.setSource(url, {
|
||||
alt: input.getName(),
|
||||
viewState,
|
||||
// Copy the displayed image, even if the file changed since it was loaded
|
||||
data: { mimeType, read: async () => new Uint8Array(await blob.arrayBuffer()) },
|
||||
});
|
||||
if (!this._isCurrentLoad(loadId, input)) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// The previous URL is revoked only now that the new image replaced it
|
||||
this._objectUrl.value = toDisposable(() => URL.revokeObjectURL(url));
|
||||
this._fileSize = content.size;
|
||||
this._setMessage(loaded ? undefined : localize('imagePreview.loadError', "An error occurred while loading the image."));
|
||||
this._onDidChangeImageInfo.fire();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows `message` instead of the image, or shows the image again if `message` is `undefined`.
|
||||
*/
|
||||
private _setMessage(message: string | undefined): void {
|
||||
this._hasMessage = message !== undefined;
|
||||
this._container?.classList.toggle('has-message', this._hasMessage);
|
||||
if (this._message) {
|
||||
this._message.label.textContent = message ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { isEqual } from '../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js';
|
||||
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { IFileService } from '../../../../platform/files/common/files.js';
|
||||
import { ILabelService } from '../../../../platform/label/common/label.js';
|
||||
import { EditorInputCapabilities, findViewStateForEditor, GroupIdentifier, IMoveResult, ISaveOptions, IUntypedEditorInput } from '../../../common/editor.js';
|
||||
import { IResourceEditorInput } from '../../../../platform/editor/common/editor.js';
|
||||
import { EditorInput } from '../../../common/editor/editorInput.js';
|
||||
import { AbstractResourceEditorInput } from '../../../common/editor/resourceEditorInput.js';
|
||||
import { ICustomEditorLabelService } from '../../../services/editor/common/customEditorLabelService.js';
|
||||
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
||||
import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js';
|
||||
import { IMAGE_PREVIEW_EDITOR_ID } from '../common/mediaPreview.js';
|
||||
import { saveMediaFileAs } from './mediaPreviewSaveAs.js';
|
||||
|
||||
/**
|
||||
* Editor input for previewing an image file.
|
||||
*/
|
||||
export class ImagePreviewEditorInput extends AbstractResourceEditorInput {
|
||||
|
||||
static readonly ID = 'workbench.input.imagePreview';
|
||||
|
||||
constructor(
|
||||
resource: URI,
|
||||
preferredResource: URI | undefined,
|
||||
@ILabelService labelService: ILabelService,
|
||||
@IFileService fileService: IFileService,
|
||||
@IFilesConfigurationService filesConfigurationService: IFilesConfigurationService,
|
||||
@ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService,
|
||||
@ICustomEditorLabelService customEditorLabelService: ICustomEditorLabelService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
@IFileDialogService private readonly _fileDialogService: IFileDialogService,
|
||||
) {
|
||||
super(resource, preferredResource, labelService, fileService, filesConfigurationService, textResourceConfigurationService, customEditorLabelService);
|
||||
}
|
||||
|
||||
override get typeId(): string {
|
||||
return ImagePreviewEditorInput.ID;
|
||||
}
|
||||
|
||||
override get editorId(): string {
|
||||
return IMAGE_PREVIEW_EDITOR_ID;
|
||||
}
|
||||
|
||||
override get capabilities(): EditorInputCapabilities {
|
||||
return (super.capabilities | EditorInputCapabilities.Readonly) & ~EditorInputCapabilities.CanDropIntoEditor;
|
||||
}
|
||||
|
||||
override matches(otherInput: EditorInput | IUntypedEditorInput): boolean {
|
||||
if (super.matches(otherInput)) {
|
||||
return true;
|
||||
}
|
||||
return otherInput instanceof ImagePreviewEditorInput && isEqual(this.resource, otherInput.resource);
|
||||
}
|
||||
|
||||
override async rename(group: GroupIdentifier, target: URI): Promise<IMoveResult> {
|
||||
// Let the resolver pick the editor for the new name, which may no longer be an image
|
||||
return {
|
||||
editor: {
|
||||
resource: target,
|
||||
options: {
|
||||
viewState: findViewStateForEditor(this, group, this._editorService),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
override async saveAs(group: GroupIdentifier, options?: ISaveOptions): Promise<EditorInput | IUntypedEditorInput | undefined> {
|
||||
const target = await saveMediaFileAs(this.resource, this.fileService, this._fileDialogService, options);
|
||||
return target ? (await this.rename(group, target)).editor : undefined;
|
||||
}
|
||||
|
||||
override toUntyped(): IResourceEditorInput {
|
||||
return {
|
||||
resource: this.preferredResource,
|
||||
options: {
|
||||
override: this.editorId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './media/imageViewer.css';
|
||||
import { $, addDisposableListener, EventHelper, EventType, getWindow, isAncestorOfActiveElement, isHTMLElement } from '../../../../base/browser/dom.js';
|
||||
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
|
||||
import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js';
|
||||
import { IAnchor } from '../../../../base/browser/ui/contextview/contextview.js';
|
||||
import { onUnexpectedError } from '../../../../base/common/errors.js';
|
||||
import { Emitter } from '../../../../base/common/event.js';
|
||||
import { KeyCode } from '../../../../base/common/keyCodes.js';
|
||||
import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { clamp } from '../../../../base/common/numbers.js';
|
||||
import { isMacintosh } from '../../../../base/common/platform.js';
|
||||
import { MenuId } from '../../../../platform/actions/common/actions.js';
|
||||
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
|
||||
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
|
||||
import { WebWorkerDescriptor } from '../../../../platform/webWorker/browser/webWorkerDescriptor.js';
|
||||
import { IWebWorkerService } from '../../../../platform/webWorker/browser/webWorkerService.js';
|
||||
import { FileAccess } from '../../../../base/common/network.js';
|
||||
import { IEditorPane } from '../../../common/editor.js';
|
||||
import { ImageEncoderWorker } from './imageEncoderWorker.js';
|
||||
|
||||
/**
|
||||
* Either a numeric zoom factor (`1` is the natural size) or `'fit'` to scale the image to fit
|
||||
* into the viewer.
|
||||
*/
|
||||
export type ImageViewerScale = number | 'fit';
|
||||
|
||||
export interface IImageViewerViewState {
|
||||
readonly scale: ImageViewerScale;
|
||||
readonly scrollLeft: number;
|
||||
readonly scrollTop: number;
|
||||
}
|
||||
|
||||
export interface IImageViewerOptions {
|
||||
/**
|
||||
* The element that holds keyboard focus for the viewer. Defaults to the viewer element.
|
||||
*
|
||||
* It is used to decide whether a click should zoom (clicks that only move focus into the
|
||||
* viewer don't) and whether copying applies to the viewer. It also receives the native `copy`
|
||||
* event, modifier key changes and context menu requests from the keyboard.
|
||||
*/
|
||||
readonly focusTarget?: HTMLElement;
|
||||
}
|
||||
|
||||
export interface IImageViewerSourceOptions {
|
||||
/**
|
||||
* Alternative text for the image.
|
||||
*/
|
||||
readonly alt: string;
|
||||
|
||||
/**
|
||||
* View state to restore once the image is shown. When omitted, the image is scaled to fit.
|
||||
*/
|
||||
readonly viewState?: IImageViewerViewState;
|
||||
|
||||
/**
|
||||
* The encoded image. PNG images are copied as is instead of being encoded again.
|
||||
*/
|
||||
readonly data?: IImageViewerSourceData;
|
||||
}
|
||||
|
||||
export interface IImageViewerSourceData {
|
||||
readonly mimeType: string;
|
||||
read(): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An editor pane that renders its content with an {@link ImageViewer}.
|
||||
*/
|
||||
export interface IImageViewerEditorPane extends IEditorPane {
|
||||
/**
|
||||
* The viewer that shows the current image, if any.
|
||||
*/
|
||||
readonly imageViewer: ImageViewer | undefined;
|
||||
}
|
||||
|
||||
export function isImageViewerEditorPane(pane: IEditorPane | undefined): pane is IImageViewerEditorPane {
|
||||
return !!pane && (pane as IImageViewerEditorPane).imageViewer instanceof ImageViewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu shown when right clicking an image in an {@link ImageViewer}. Commands contributed
|
||||
* to it are invoked with the viewer as their first argument.
|
||||
*/
|
||||
export const ImageViewerContextMenuId = new MenuId('ImageViewerContext');
|
||||
|
||||
/**
|
||||
* Enable `image-rendering: pixelated` for images scaled by more than this.
|
||||
*/
|
||||
const PIXELATION_THRESHOLD = 3;
|
||||
|
||||
const SCALE_PINCH_FACTOR = 0.075;
|
||||
const MAX_SCALE = 20;
|
||||
const MIN_SCALE = 0.1;
|
||||
|
||||
const ZOOM_LEVELS: readonly number[] = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.5, 2, 3, 5, 7, 10, 15, 20];
|
||||
|
||||
/**
|
||||
* Returns the next predefined zoom level when zooming in from `scale`.
|
||||
*/
|
||||
export function getNextZoomLevel(scale: number): number {
|
||||
return ZOOM_LEVELS.find(level => level > scale) ?? MAX_SCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous predefined zoom level when zooming out from `scale`.
|
||||
*/
|
||||
export function getPreviousZoomLevel(scale: number): number {
|
||||
return ZOOM_LEVELS.findLast(level => level < scale) ?? MIN_SCALE;
|
||||
}
|
||||
|
||||
interface IImageSize {
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
}
|
||||
|
||||
function getLargerDimension(width: number, height: number): IImageSize {
|
||||
return width >= height ? { width } : { height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a single image with support for zooming (click, modifier + scroll wheel and pinch),
|
||||
* scrolling around zoomed images, and copying the image. Shared by the image preview editor and
|
||||
* the image carousel.
|
||||
*/
|
||||
export class ImageViewer extends Disposable {
|
||||
|
||||
private static readonly _instances = new Set<ImageViewer>();
|
||||
|
||||
/**
|
||||
* Gets the viewer that currently has keyboard focus, if any.
|
||||
*/
|
||||
static getFocusedViewer(): ImageViewer | undefined {
|
||||
for (const viewer of ImageViewer._instances) {
|
||||
if (viewer.hasFocus()) {
|
||||
return viewer;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
readonly element: HTMLElement;
|
||||
|
||||
private readonly _image: HTMLImageElement;
|
||||
private readonly _focusTarget: HTMLElement;
|
||||
|
||||
private _scale: ImageViewerScale = 'fit';
|
||||
private _hasImage = false;
|
||||
private _sourceRequestId = 0;
|
||||
private _sourceData: IImageViewerSourceData | undefined;
|
||||
|
||||
/**
|
||||
* Whether the image needs an explicit size to be zoomed. Images without an intrinsic width,
|
||||
* such as SVGs with only a viewBox, otherwise stretch to fill the viewer, which cancels out
|
||||
* the zoom.
|
||||
*/
|
||||
private _needsExplicitSize = false;
|
||||
|
||||
/**
|
||||
* Size of the image when it was last scaled to fit.
|
||||
*/
|
||||
private _lastFitSize: IImageSize | undefined;
|
||||
|
||||
private readonly _onDidChangeScale = this._register(new Emitter<ImageViewerScale>());
|
||||
readonly onDidChangeScale = this._onDidChangeScale.event;
|
||||
|
||||
constructor(
|
||||
options: IImageViewerOptions,
|
||||
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
|
||||
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
|
||||
@IClipboardService private readonly _clipboardService: IClipboardService,
|
||||
@IWebWorkerService private readonly _webWorkerService: IWebWorkerService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.element = $('.image-viewer');
|
||||
this._image = $<HTMLImageElement>('img.scale-to-fit');
|
||||
this._image.alt = '';
|
||||
this.element.appendChild(this._image);
|
||||
this._focusTarget = options.focusTarget ?? this.element;
|
||||
|
||||
ImageViewer._instances.add(this);
|
||||
this._register(toDisposable(() => ImageViewer._instances.delete(this)));
|
||||
|
||||
this._registerListeners();
|
||||
}
|
||||
|
||||
get scale(): ImageViewerScale {
|
||||
return this._scale;
|
||||
}
|
||||
|
||||
get hasImage(): boolean {
|
||||
return this._hasImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether keyboard focus is in the viewer or its focus target.
|
||||
*/
|
||||
hasFocus(): boolean {
|
||||
return isAncestorOfActiveElement(this._focusTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* The natural size of the current image, if it has loaded.
|
||||
*/
|
||||
get naturalSize(): { readonly width: number; readonly height: number } | undefined {
|
||||
if (!this._hasImage) {
|
||||
return undefined;
|
||||
}
|
||||
return { width: this._image.naturalWidth, height: this._image.naturalHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the image at `src`. The image is decoded before it replaces the current one, so
|
||||
* switching between images doesn't flash.
|
||||
*
|
||||
* @returns `true` if the image was decoded and shown, `false` if it failed to decode or a
|
||||
* newer call superseded this one.
|
||||
*/
|
||||
async setSource(src: string, options: IImageViewerSourceOptions): Promise<boolean> {
|
||||
const requestId = ++this._sourceRequestId;
|
||||
|
||||
const loader = new Image();
|
||||
loader.src = src;
|
||||
let decoded = true;
|
||||
try {
|
||||
await loader.decode();
|
||||
} catch {
|
||||
decoded = false;
|
||||
}
|
||||
|
||||
if (requestId !== this._sourceRequestId || this._store.isDisposed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._image.src = src;
|
||||
this._image.alt = options.alt;
|
||||
this._sourceData = options.data;
|
||||
this._hasImage = decoded;
|
||||
this.element.classList.toggle('ready', decoded);
|
||||
if (!decoded) {
|
||||
this._applyScale('fit');
|
||||
return false;
|
||||
}
|
||||
|
||||
this._needsExplicitSize = !this._hasIntrinsicWidth(loader);
|
||||
this._lastFitSize = undefined;
|
||||
this._applyScale('fit');
|
||||
|
||||
const viewState = options.viewState;
|
||||
if (viewState && viewState.scale !== 'fit') {
|
||||
this._applyScale(viewState.scale);
|
||||
this.element.scrollTo(viewState.scrollLeft, viewState.scrollTop);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards an image that is still loading, so it doesn't replace the current one.
|
||||
*/
|
||||
cancelPendingSource(): void {
|
||||
this._sourceRequestId++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the current image.
|
||||
*/
|
||||
clear(): void {
|
||||
this._sourceRequestId++;
|
||||
this._applyScale('fit');
|
||||
this._hasImage = false;
|
||||
this.element.classList.remove('ready');
|
||||
this._image.removeAttribute('src');
|
||||
this._image.alt = '';
|
||||
this._sourceData = undefined;
|
||||
}
|
||||
|
||||
getViewState(): IImageViewerViewState {
|
||||
return {
|
||||
scale: this._scale,
|
||||
scrollLeft: this.element.scrollLeft,
|
||||
scrollTop: this.element.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
setScale(scale: ImageViewerScale): void {
|
||||
this._applyScale(scale);
|
||||
}
|
||||
|
||||
zoomIn(): void {
|
||||
if (this._hasImage) {
|
||||
this._applyScale(getNextZoomLevel(this._getNumericScale()));
|
||||
}
|
||||
}
|
||||
|
||||
zoomOut(): void {
|
||||
if (this._hasImage) {
|
||||
this._applyScale(getPreviousZoomLevel(this._getNumericScale()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the current image to the clipboard as a PNG.
|
||||
*/
|
||||
async copyImage(): Promise<void> {
|
||||
if (!this._hasImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass the pending image instead of awaiting it, so the clipboard write starts while the
|
||||
// user gesture that triggered the copy is still being handled
|
||||
const data = this._sourceData;
|
||||
const png = data?.mimeType === 'image/png' ? this._readPng(data) : this._encodeAsPng();
|
||||
return this._clipboardService.writeImage(png);
|
||||
}
|
||||
|
||||
private _registerListeners(): void {
|
||||
this._register(addDisposableListener(this.element, EventType.MOUSE_WHEEL, (e: WheelEvent) => {
|
||||
// Pinching is reported as scroll wheel + ctrl. Never let it zoom the whole window.
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!this._hasImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isZoomModifier = isMacintosh ? e.altKey : e.ctrlKey;
|
||||
if (!isZoomModifier && !e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (e.deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = e.deltaY > 0 ? 1 : -1;
|
||||
this._applyScale(this._getNumericScale() * (1 - delta * SCALE_PINCH_FACTOR));
|
||||
}, { passive: false }));
|
||||
|
||||
// Track focus and modifiers at mouse down, since focus moves before the click event fires
|
||||
let consumeClick = false;
|
||||
let zoomOutOnClick = false;
|
||||
this._register(addDisposableListener(this.element, EventType.MOUSE_DOWN, (e: MouseEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
consumeClick = !isAncestorOfActiveElement(this._focusTarget);
|
||||
zoomOutOnClick = isMacintosh ? e.altKey : e.ctrlKey;
|
||||
}));
|
||||
|
||||
this._register(addDisposableListener(this.element, EventType.CLICK, (e: MouseEvent) => {
|
||||
if (e.button !== 0 || !this._hasImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't zoom when the click only focuses the viewer
|
||||
if (consumeClick) {
|
||||
consumeClick = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (zoomOutOnClick) {
|
||||
this.zoomOut();
|
||||
} else {
|
||||
this.zoomIn();
|
||||
}
|
||||
}));
|
||||
|
||||
const updateZoomOutCursor = (e: KeyboardEvent | MouseEvent) => {
|
||||
this.element.classList.toggle('zoom-out', isMacintosh ? e.altKey : e.ctrlKey);
|
||||
};
|
||||
this._register(addDisposableListener(this._focusTarget, EventType.KEY_DOWN, updateZoomOutCursor));
|
||||
this._register(addDisposableListener(this._focusTarget, EventType.KEY_UP, updateZoomOutCursor));
|
||||
this._register(addDisposableListener(this.element, EventType.MOUSE_MOVE, updateZoomOutCursor));
|
||||
|
||||
// Handle the browser's native copy, since the copy command has no keybinding in browsers. Text
|
||||
// selected next to the image, such as the carousel's caption, is copied as usual.
|
||||
this._register(addDisposableListener(this._focusTarget, 'copy', (e: ClipboardEvent) => {
|
||||
const selection = getWindow(this.element).getSelection();
|
||||
const hasSelectedText = !!selection && !selection.isCollapsed && this._focusTarget.contains(selection.anchorNode);
|
||||
if (!this._hasImage || !this.hasFocus() || hasSelectedText) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this.copyImage().catch(onUnexpectedError);
|
||||
}));
|
||||
|
||||
// The context menu key and Shift+F10 target the focused element, which is the focus target
|
||||
// rather than the viewer when they differ, so handle them on the focus target like lists do
|
||||
let didPressContextMenuKey = false;
|
||||
this._register(addDisposableListener(this._focusTarget, EventType.KEY_DOWN, (e: KeyboardEvent) => {
|
||||
didPressContextMenuKey = this._isContextMenuKey(e);
|
||||
if (didPressContextMenuKey) {
|
||||
EventHelper.stop(e, true);
|
||||
}
|
||||
}));
|
||||
this._register(addDisposableListener(this._focusTarget, EventType.KEY_UP, (e: KeyboardEvent) => {
|
||||
didPressContextMenuKey = false;
|
||||
if (this._isContextMenuKey(e)) {
|
||||
EventHelper.stop(e, true);
|
||||
this._showContextMenu(() => this._getKeyboardContextMenuAnchor());
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(addDisposableListener(this.element, EventType.CONTEXT_MENU, (e: MouseEvent) => {
|
||||
if (didPressContextMenuKey) {
|
||||
EventHelper.stop(e, true);
|
||||
return;
|
||||
}
|
||||
if (!this._hasImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
EventHelper.stop(e, true);
|
||||
this._showContextMenu(() => new StandardMouseEvent(getWindow(this.element), e));
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `e` presses the context menu key or Shift+F10 while the viewer, or the focus target
|
||||
* itself, has focus. Other elements in the focus target, such as buttons, keep their own
|
||||
* context menus.
|
||||
*/
|
||||
private _isContextMenuKey(e: KeyboardEvent): boolean {
|
||||
const event = new StandardKeyboardEvent(e);
|
||||
if (!this._hasImage || (event.keyCode !== KeyCode.ContextMenu && !(event.shiftKey && event.keyCode === KeyCode.F10))) {
|
||||
return false;
|
||||
}
|
||||
return e.target === this._focusTarget || (isHTMLElement(e.target) && this.element.contains(e.target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchors a context menu opened from the keyboard at the center of the viewer. The image is
|
||||
* centered in the viewer, or fills it when zoomed in.
|
||||
*/
|
||||
private _getKeyboardContextMenuAnchor(): IAnchor {
|
||||
const viewer = this.element.getBoundingClientRect();
|
||||
return { x: viewer.left + viewer.width / 2, y: viewer.top + viewer.height / 2 };
|
||||
}
|
||||
|
||||
private _showContextMenu(getAnchor: () => StandardMouseEvent | IAnchor): void {
|
||||
this._contextMenuService.showContextMenu({
|
||||
menuId: ImageViewerContextMenuId,
|
||||
menuActionOptions: { arg: this, shouldForwardArgs: true },
|
||||
contextKeyService: this._contextKeyService,
|
||||
getAnchor,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current scale as a number, computing the effective scale when the image is
|
||||
* scaled to fit.
|
||||
*/
|
||||
private _getNumericScale(): number {
|
||||
if (this._scale !== 'fit') {
|
||||
return this._scale;
|
||||
}
|
||||
|
||||
const size = this._needsExplicitSize ? this._getBaseSize() : { width: this._image.naturalWidth };
|
||||
if (size?.width) {
|
||||
return this._image.clientWidth / size.width;
|
||||
}
|
||||
if (size?.height) {
|
||||
return this._image.clientHeight / size.height;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies fit-to-viewer or a numeric zoom while preserving the visible center of the image.
|
||||
*/
|
||||
private _applyScale(newScale: ImageViewerScale): void {
|
||||
const container = this.element;
|
||||
const image = this._image;
|
||||
|
||||
if (newScale === 'fit') {
|
||||
const changed = this._scale !== 'fit';
|
||||
this._scale = 'fit';
|
||||
image.classList.add('scale-to-fit');
|
||||
image.classList.remove('pixelated');
|
||||
image.style.zoom = '';
|
||||
image.style.width = '';
|
||||
image.style.height = '';
|
||||
|
||||
// Remove the overflow before scrolling to avoid an expensive synchronous scroll layer update
|
||||
const wasZoomed = container.classList.contains('zoomed');
|
||||
container.classList.remove('zoomed');
|
||||
if (wasZoomed) {
|
||||
container.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this._onDidChangeScale.fire(this._scale);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._hasImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = clamp(newScale, MIN_SCALE, MAX_SCALE);
|
||||
this._scale = scale;
|
||||
|
||||
// Capture the scroll center ratio before changing the zoom
|
||||
const dx = container.scrollWidth > 0 ? (container.scrollLeft + container.clientWidth / 2) / container.scrollWidth : 0.5;
|
||||
const dy = container.scrollHeight > 0 ? (container.scrollTop + container.clientHeight / 2) / container.scrollHeight : 0.5;
|
||||
|
||||
// Must be read before leaving scale-to-fit since it may depend on the fitted size
|
||||
const size = this._needsExplicitSize ? this._getBaseSize() : undefined;
|
||||
|
||||
image.classList.remove('scale-to-fit');
|
||||
image.classList.toggle('pixelated', scale >= PIXELATION_THRESHOLD);
|
||||
image.style.width = size?.width ? `${size.width}px` : '';
|
||||
image.style.height = size?.height ? `${size.height}px` : '';
|
||||
image.style.zoom = String(scale);
|
||||
container.classList.add('zoomed');
|
||||
|
||||
// Setting the zoom triggers a synchronous layout, so the scroll size reflects the new zoom
|
||||
container.scrollTo(
|
||||
container.scrollWidth * dx - container.clientWidth / 2,
|
||||
container.scrollHeight * dy - container.clientHeight / 2,
|
||||
);
|
||||
|
||||
this._onDidChangeScale.fire(this._scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the size of the image at a zoom level of 1.
|
||||
*
|
||||
* Only the larger dimension is returned as the other one follows from the aspect ratio. Natural
|
||||
* sizes are rounded, so the smaller dimension of a very thin SVG may otherwise be reported as 0.
|
||||
*/
|
||||
private _getBaseSize(): IImageSize | undefined {
|
||||
const image = this._image;
|
||||
if (image.classList.contains('scale-to-fit') && (image.clientWidth || image.clientHeight)) {
|
||||
this._lastFitSize = getLargerDimension(image.clientWidth, image.clientHeight);
|
||||
}
|
||||
|
||||
if (image.naturalWidth || image.naturalHeight) {
|
||||
return getLargerDimension(image.naturalWidth, image.naturalHeight);
|
||||
}
|
||||
|
||||
// Images without intrinsic dimensions (e.g. SVGs with only a viewBox) may report no natural
|
||||
// size. Fall back to the size the image had when it was last scaled to fit.
|
||||
return this._lastFitSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the image keeps its natural width in a container that is much wider than it.
|
||||
*/
|
||||
private _hasIntrinsicWidth(image: HTMLImageElement): boolean {
|
||||
const probe = $('div');
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.width = '100000px';
|
||||
probe.appendChild(image);
|
||||
getWindow(this.element).document.body.appendChild(probe);
|
||||
const result = Math.abs(image.clientWidth - image.naturalWidth) <= 1;
|
||||
probe.remove();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the source PNG so it can be copied without encoding it again, which is both faster and
|
||||
* lossless. Falls back to encoding when the data turns out not to be a PNG.
|
||||
*/
|
||||
private async _readPng(data: IImageViewerSourceData): Promise<Uint8Array> {
|
||||
try {
|
||||
const bytes = await data.read();
|
||||
if (isPng(bytes)) {
|
||||
return bytes;
|
||||
}
|
||||
} catch {
|
||||
// Encode the displayed image instead
|
||||
}
|
||||
return this._encodeAsPng();
|
||||
}
|
||||
|
||||
private async _encodeAsPng(): Promise<Uint8Array> {
|
||||
const width = this._image.naturalWidth || this._image.clientWidth;
|
||||
const height = this._image.naturalHeight || this._image.clientHeight;
|
||||
const canvas = width && height ? new OffscreenCanvas(width, height) : undefined;
|
||||
const context = canvas?.getContext('2d');
|
||||
if (!canvas || !context) {
|
||||
throw new Error('Failed to encode image');
|
||||
}
|
||||
|
||||
// Draw on the main thread so that EXIF orientation, color conversion and the current frame of
|
||||
// animated images match what is shown
|
||||
context.drawImage(this._image, 0, 0, width, height);
|
||||
const bitmap = canvas.transferToImageBitmap();
|
||||
try {
|
||||
return await this._encodeInWorker(bitmap);
|
||||
} catch {
|
||||
// For example when workers can't be created
|
||||
return await this._encodeOnMainThread(bitmap);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes in a worker, since the main thread encodes canvases using idle tasks, which can be
|
||||
* starved for seconds while the renderer is busy.
|
||||
*/
|
||||
private async _encodeInWorker(bitmap: ImageBitmap): Promise<Uint8Array> {
|
||||
const worker = this._webWorkerService.createWorkerClient<ImageEncoderWorker>(new WebWorkerDescriptor({
|
||||
esmModuleLocation: FileAccess.asBrowserUri('vs/workbench/contrib/mediaPreview/browser/imageEncoderWorkerMain.js'),
|
||||
label: 'ImageEncoderWorker',
|
||||
}));
|
||||
try {
|
||||
return new Uint8Array(await worker.proxy.$encodePng(bitmap));
|
||||
} finally {
|
||||
worker.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private _encodeOnMainThread(bitmap: ImageBitmap): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = $<HTMLCanvasElement>('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
reject(new Error('Failed to encode image'));
|
||||
return;
|
||||
}
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
canvas.toBlob(async blob => {
|
||||
canvas.width = canvas.height = 0;
|
||||
if (blob) {
|
||||
resolve(new Uint8Array(await blob.arrayBuffer()));
|
||||
} else {
|
||||
reject(new Error('Failed to encode image'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
function isPng(data: Uint8Array): boolean {
|
||||
return data.length >= pngSignature.length && pngSignature.every((byte, i) => data[i] === byte);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.image-preview-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Keep the workbench focus ring for keyboard focus only */
|
||||
.image-preview-editor .image-viewer:focus:not(:focus-visible) {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.image-preview-editor .image-viewer img.scale-to-fit {
|
||||
max-width: calc(100% - 2 * var(--vscode-spacing-size100));
|
||||
max-height: calc(100% - 2 * var(--vscode-spacing-size100));
|
||||
}
|
||||
|
||||
/* Checkerboard behind transparent images */
|
||||
.image-preview-editor .image-viewer img {
|
||||
background-size: 16px 16px;
|
||||
background-image: conic-gradient(rgb(230, 230, 230) 25%, transparent 25% 50%, rgb(230, 230, 230) 50% 75%, transparent 75%);
|
||||
}
|
||||
|
||||
.vs-dark .image-preview-editor .image-viewer img,
|
||||
.hc-black .image-preview-editor .image-viewer img {
|
||||
background-image: conic-gradient(rgb(20, 20, 20) 25%, transparent 25% 50%, rgb(20, 20, 20) 50% 75%, transparent 75%);
|
||||
}
|
||||
|
||||
/* Shown instead of the image when it can't be previewed, laid out like the editor placeholder */
|
||||
.image-preview-editor .image-preview-message {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-preview-editor:not(.has-message) .image-preview-message,
|
||||
.image-preview-editor.has-message .image-viewer {
|
||||
display: none;
|
||||
}
|
||||
@@ -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.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.image-viewer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer.ready {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.image-viewer.ready.zoom-out {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.image-viewer img {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.image-viewer img.scale-to-fit {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Zoomed images may be larger than the viewer. Keep them at their explicit size */
|
||||
.image-viewer img:not(.scale-to-fit) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.image-viewer img.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.image-viewer.zoomed {
|
||||
overflow: auto;
|
||||
scrollbar-color: var(--vscode-scrollbarSlider-background) transparent;
|
||||
}
|
||||
|
||||
.image-viewer.zoomed::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.image-viewer.zoomed::-webkit-scrollbar-thumb {
|
||||
background-color: var(--vscode-scrollbarSlider-background);
|
||||
}
|
||||
|
||||
.image-viewer.zoomed::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--vscode-scrollbarSlider-hoverBackground);
|
||||
}
|
||||
|
||||
.image-viewer.zoomed::-webkit-scrollbar-thumb:active {
|
||||
background-color: var(--vscode-scrollbarSlider-activeBackground);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RunOnceScheduler } from '../../../../base/common/async.js';
|
||||
import { Emitter } from '../../../../base/common/event.js';
|
||||
import { escapeDoubleQuotes } from '../../../../base/common/htmlContent.js';
|
||||
import { basename, dirname, isEqual } from '../../../../base/common/resources.js';
|
||||
import { escape } from '../../../../base/common/strings.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { FileChangesEvent, FileChangeType, IFileService } from '../../../../platform/files/common/files.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { ILabelService } from '../../../../platform/label/common/label.js';
|
||||
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
|
||||
import { EditorInputCapabilities, GroupIdentifier, IMoveResult, ISaveOptions, IUntypedEditorInput, Verbosity } from '../../../common/editor.js';
|
||||
import { IResourceEditorInput } from '../../../../platform/editor/common/editor.js';
|
||||
import { EditorInput } from '../../../common/editor/editorInput.js';
|
||||
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
||||
import { IOverlayWebview, IWebviewService } from '../../webview/browser/webview.js';
|
||||
import { asWebviewUri, webviewGenericCspSource } from '../../webview/common/webview.js';
|
||||
import { WebviewInput } from '../../webviewPanel/browser/webviewEditorInput.js';
|
||||
import { AUDIO_PREVIEW_EDITOR_ID, isGitLfsPointer, VIDEO_PREVIEW_AUTO_PLAY_SETTING, VIDEO_PREVIEW_EDITOR_ID, VIDEO_PREVIEW_LOOP_SETTING } from '../common/mediaPreview.js';
|
||||
import { reopenInTextEditor } from './mediaPreviewErrors.js';
|
||||
import { saveMediaFileAs } from './mediaPreviewSaveAs.js';
|
||||
|
||||
export type MediaPlayerKind = 'audio' | 'video';
|
||||
|
||||
export function isMediaPlayerKind(value: unknown): value is MediaPlayerKind {
|
||||
return value === 'audio' || value === 'video';
|
||||
}
|
||||
|
||||
function getMediaPlayerEditorId(kind: MediaPlayerKind): string {
|
||||
return kind === 'audio' ? AUDIO_PREVIEW_EDITOR_ID : VIDEO_PREVIEW_EDITOR_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor input for previewing an audio or video file. Media plays in a webview since the
|
||||
* workbench doesn't allow loading media from arbitrary resources. Opens in the
|
||||
* {@link WebviewEditor}, which keeps the webview alive while the editor is hidden so playback
|
||||
* isn't interrupted.
|
||||
*/
|
||||
export class MediaPlayerEditorInput extends WebviewInput {
|
||||
|
||||
static override readonly typeId = 'workbench.input.mediaPlayer';
|
||||
|
||||
static create(instantiationService: IInstantiationService, kind: MediaPlayerKind, resource: URI, group: GroupIdentifier | undefined): MediaPlayerEditorInput {
|
||||
return instantiationService.invokeFunction(accessor => {
|
||||
const webview = accessor.get(IWebviewService).createWebviewOverlay({
|
||||
providedViewType: getMediaPlayerEditorId(kind),
|
||||
title: basename(resource),
|
||||
options: { retainContextWhenHidden: true },
|
||||
contentOptions: {
|
||||
allowScripts: true,
|
||||
localResourceRoots: [dirname(resource).with({ query: '', fragment: '' })],
|
||||
},
|
||||
extension: undefined,
|
||||
});
|
||||
const input = instantiationService.createInstance(MediaPlayerEditorInput, kind, resource, webview);
|
||||
if (typeof group === 'number') {
|
||||
input.updateGroup(group);
|
||||
}
|
||||
return input;
|
||||
});
|
||||
}
|
||||
|
||||
private _fileSize: number | undefined;
|
||||
private _rendered: Promise<void> | undefined;
|
||||
|
||||
private readonly _onDidChangeFileSize = this._register(new Emitter<void>());
|
||||
readonly onDidChangeFileSize = this._onDidChangeFileSize.event;
|
||||
|
||||
constructor(
|
||||
readonly kind: MediaPlayerKind,
|
||||
private readonly _resource: URI,
|
||||
webview: IOverlayWebview,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@ILabelService private readonly _labelService: ILabelService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IFileDialogService private readonly _fileDialogService: IFileDialogService,
|
||||
) {
|
||||
super({ viewType: getMediaPlayerEditorId(kind), providedId: undefined, name: basename(_resource), iconPath: undefined }, webview, themeService);
|
||||
|
||||
this._register(webview.onMessage(e => {
|
||||
if (e.message?.type === 'reopen-as-text') {
|
||||
void reopenInTextEditor(this, this._editorService);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
override get typeId(): string {
|
||||
return MediaPlayerEditorInput.typeId;
|
||||
}
|
||||
|
||||
override get resource(): URI {
|
||||
return this._resource;
|
||||
}
|
||||
|
||||
override get capabilities(): EditorInputCapabilities {
|
||||
return EditorInputCapabilities.Readonly | EditorInputCapabilities.CanDropIntoEditor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Size of the media file in bytes.
|
||||
*/
|
||||
get fileSize(): number | undefined {
|
||||
return this._fileSize;
|
||||
}
|
||||
|
||||
override getName(): string {
|
||||
return this._labelService.getUriBasenameLabel(this._resource);
|
||||
}
|
||||
|
||||
override getDescription(verbosity = Verbosity.MEDIUM): string | undefined {
|
||||
const folder = dirname(this._resource);
|
||||
switch (verbosity) {
|
||||
case Verbosity.SHORT:
|
||||
return this._labelService.getUriBasenameLabel(folder);
|
||||
case Verbosity.LONG:
|
||||
return this._labelService.getUriLabel(folder);
|
||||
case Verbosity.MEDIUM:
|
||||
default:
|
||||
return this._labelService.getUriLabel(folder, { relative: true });
|
||||
}
|
||||
}
|
||||
|
||||
override getTitle(verbosity?: Verbosity): string {
|
||||
switch (verbosity) {
|
||||
case Verbosity.SHORT:
|
||||
return this.getName();
|
||||
case Verbosity.LONG:
|
||||
return this._labelService.getUriLabel(this._resource);
|
||||
case Verbosity.MEDIUM:
|
||||
default:
|
||||
return this._labelService.getUriLabel(this._resource, { relative: true });
|
||||
}
|
||||
}
|
||||
|
||||
override matches(other: EditorInput | IUntypedEditorInput): boolean {
|
||||
return super.matches(other) || (other instanceof MediaPlayerEditorInput && this.kind === other.kind && isEqual(this._resource, other._resource));
|
||||
}
|
||||
|
||||
override copy(): EditorInput {
|
||||
return MediaPlayerEditorInput.create(this._instantiationService, this.kind, this._resource, this.group);
|
||||
}
|
||||
|
||||
override async rename(_group: GroupIdentifier, target: URI): Promise<IMoveResult> {
|
||||
// Let the resolver pick the editor for the new name, which may no longer be a media file
|
||||
return { editor: { resource: target } };
|
||||
}
|
||||
|
||||
override async saveAs(group: GroupIdentifier, options?: ISaveOptions): Promise<EditorInput | IUntypedEditorInput | undefined> {
|
||||
const target = await saveMediaFileAs(this._resource, this._fileService, this._fileDialogService, options);
|
||||
return target ? (await this.rename(group, target)).editor : undefined;
|
||||
}
|
||||
|
||||
override toUntyped(): IResourceEditorInput {
|
||||
return {
|
||||
resource: this._resource,
|
||||
options: {
|
||||
override: this.editorId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
override async resolve(): Promise<null> {
|
||||
this._rendered ??= this._initialRender();
|
||||
await this._rendered;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async _initialRender(): Promise<void> {
|
||||
await this._render();
|
||||
if (this.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderScheduler = this._register(new RunOnceScheduler(() => void this._render(), 100));
|
||||
const onDidFilesChange = (e: FileChangesEvent) => {
|
||||
if (e.contains(this._resource, FileChangeType.UPDATED, FileChangeType.ADDED)) {
|
||||
renderScheduler.schedule();
|
||||
}
|
||||
};
|
||||
const watcher = this._register(this._fileService.createWatcher(this._resource, { recursive: false, excludes: [] }));
|
||||
this._register(watcher.onDidChange(onDidFilesChange));
|
||||
// File systems contributed by extensions only report uncorrelated changes
|
||||
this._register(this._fileService.onDidFilesChange(onDidFilesChange));
|
||||
}
|
||||
|
||||
private async _render(): Promise<void> {
|
||||
// The message is shown in the webview rather than as an editor error, so the other side of
|
||||
// a diff stays visible
|
||||
const isGitLfs = await isGitLfsPointer(this._fileService, this._resource);
|
||||
if (this.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.webview.setHtml(this._getHtml(isGitLfs));
|
||||
void this._updateFileSize();
|
||||
}
|
||||
|
||||
private async _updateFileSize(): Promise<void> {
|
||||
try {
|
||||
const stat = await this._fileService.stat(this._resource);
|
||||
this._fileSize = stat.size;
|
||||
} catch {
|
||||
this._fileSize = undefined;
|
||||
}
|
||||
|
||||
if (!this.isDisposed()) {
|
||||
this._onDidChangeFileSize.fire();
|
||||
}
|
||||
}
|
||||
|
||||
private _getHtml(isGitLfs: boolean): string {
|
||||
// Avoid cache busting if there is already a query string
|
||||
const webviewUri = asWebviewUri(this._resource);
|
||||
const src = this._resource.query ? webviewUri : webviewUri.with({ query: `version=${Date.now()}` });
|
||||
const nonce = generateUuid();
|
||||
|
||||
let media: string;
|
||||
let errorMessage: string;
|
||||
if (this.kind === 'audio') {
|
||||
media = `<audio src="${escapeDoubleQuotes(escape(src.toString()))}" controls></audio>`;
|
||||
errorMessage = isGitLfs
|
||||
? localize('audioPreview.gitLfs', "The audio file is stored with Git LFS and is not available for preview.")
|
||||
: localize('audioPreview.loadError', "An error occurred while loading the audio file.");
|
||||
} else {
|
||||
const autoPlay = this._configurationService.getValue<boolean>(VIDEO_PREVIEW_AUTO_PLAY_SETTING) === true;
|
||||
const loop = this._configurationService.getValue<boolean>(VIDEO_PREVIEW_LOOP_SETTING) === true;
|
||||
media = `<video src="${escapeDoubleQuotes(escape(src.toString()))}" controls playsinline${autoPlay ? ' autoplay muted' : ''}${loop ? ' loop' : ''}></video>`;
|
||||
errorMessage = isGitLfs
|
||||
? localize('videoPreview.gitLfs', "The video file is stored with Git LFS and is not available for preview.")
|
||||
: localize('videoPreview.loadError', "An error occurred while loading the video file.");
|
||||
}
|
||||
if (isGitLfs) {
|
||||
// The file only contains a pointer to the media, so don't try to play it
|
||||
media = '';
|
||||
}
|
||||
const openInTextEditor = localize('mediaPreview.openInTextEditor', "Open in Text Editor");
|
||||
|
||||
return /* html */`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; media-src ${webviewGenericCspSource}; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}';">
|
||||
<style nonce="${nonce}">
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.load-error {
|
||||
display: none;
|
||||
}
|
||||
body.error audio,
|
||||
body.error video {
|
||||
display: none;
|
||||
}
|
||||
body.error .load-error {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body${isGitLfs ? ' class="error"' : ''} data-vscode-context='{ "preventDefaultContextMenuItems": true }'>
|
||||
${media}
|
||||
<div class="load-error">
|
||||
<p>${escape(errorMessage)}</p>
|
||||
<a href="#" class="open-in-text-editor">${escape(openInTextEditor)}</a>
|
||||
</div>
|
||||
<script nonce="${nonce}">
|
||||
(function () {
|
||||
const vscode = acquireVsCodeApi();
|
||||
document.querySelector('audio, video')?.addEventListener('error', () => {
|
||||
document.body.classList.add('error');
|
||||
});
|
||||
document.querySelector('.open-in-text-editor').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
vscode.postMessage({ type: 'reopen-as-text' });
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { URI, UriComponents } from '../../../../base/common/uri.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { registerAction2 } from '../../../../platform/actions/common/actions.js';
|
||||
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
|
||||
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { Registry } from '../../../../platform/registry/common/platform.js';
|
||||
import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js';
|
||||
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
|
||||
import { DEFAULT_EDITOR_ASSOCIATION, EditorExtensions, EditorInputWithOptions, EditorsOrder, IEditorFactoryRegistry, IEditorSerializer, IResourceDiffEditorInput } from '../../../common/editor.js';
|
||||
import { DiffEditorInput } from '../../../common/editor/diffEditorInput.js';
|
||||
import { EditorInput } from '../../../common/editor/editorInput.js';
|
||||
import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';
|
||||
import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js';
|
||||
import { IEditorService, IUntypedEditorReplacement } from '../../../services/editor/common/editorService.js';
|
||||
import { CustomEditorInput } from '../../customEditor/browser/customEditorInput.js';
|
||||
import { AUDIO_PREVIEW_EDITOR_ID, AUDIO_PREVIEW_FILE_PATTERN, IMAGE_PREVIEW_EDITOR_ID, IMAGE_PREVIEW_FILE_PATTERN, VIDEO_PREVIEW_AUTO_PLAY_SETTING, VIDEO_PREVIEW_EDITOR_ID, VIDEO_PREVIEW_FILE_PATTERN, VIDEO_PREVIEW_LOOP_SETTING } from '../common/mediaPreview.js';
|
||||
import { ActiveImagePreviewContextKeyContribution } from './activeMediaPreview.js';
|
||||
import { ImagePreviewEditor } from './imagePreviewEditor.js';
|
||||
import { ImagePreviewEditorInput } from './imagePreviewEditorInput.js';
|
||||
import { CopyImageAction, registerImageViewerCopyCommand, ReopenImageAsPreviewAction, ReopenImageAsTextAction, SelectImageZoomLevelAction, ZoomInImageAction, ZoomOutImageAction } from './mediaPreviewActions.js';
|
||||
import { isMediaPlayerKind, MediaPlayerEditorInput, MediaPlayerKind } from './mediaPlayerEditorInput.js';
|
||||
import { MediaPreviewStatusBarContribution } from './mediaPreviewStatusBar.js';
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({
|
||||
id: 'mediaPreview',
|
||||
title: localize('mediaPreviewConfigurationTitle', "Media Preview"),
|
||||
type: 'object',
|
||||
properties: {
|
||||
[VIDEO_PREVIEW_AUTO_PLAY_SETTING]: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
markdownDescription: localize('mediaPreview.video.autoPlay', "Start playing videos on mute automatically."),
|
||||
},
|
||||
[VIDEO_PREVIEW_LOOP_SETTING]: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
markdownDescription: localize('mediaPreview.video.loop', "Loop videos over again automatically."),
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// --- Editor Panes ---
|
||||
|
||||
// Audio and video open in the webview editor, which is registered for all `WebviewInput`s
|
||||
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
|
||||
EditorPaneDescriptor.create(
|
||||
ImagePreviewEditor,
|
||||
ImagePreviewEditor.ID,
|
||||
localize('imagePreviewEditor', "Image Preview")
|
||||
),
|
||||
[
|
||||
new SyncDescriptor(ImagePreviewEditorInput)
|
||||
]
|
||||
);
|
||||
|
||||
// --- Serializers ---
|
||||
|
||||
interface ISerializedImagePreviewInput {
|
||||
readonly resource: UriComponents;
|
||||
readonly preferredResource: UriComponents;
|
||||
}
|
||||
|
||||
interface ISerializedMediaPlayerInput {
|
||||
readonly kind: MediaPlayerKind;
|
||||
readonly resource: UriComponents;
|
||||
}
|
||||
|
||||
class ImagePreviewEditorInputSerializer implements IEditorSerializer {
|
||||
|
||||
canSerialize(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
serialize(input: ImagePreviewEditorInput): string {
|
||||
return JSON.stringify({
|
||||
resource: input.resource.toJSON(),
|
||||
preferredResource: input.preferredResource.toJSON(),
|
||||
} satisfies ISerializedImagePreviewInput);
|
||||
}
|
||||
|
||||
deserialize(instantiationService: IInstantiationService, serializedEditor: string): ImagePreviewEditorInput {
|
||||
const data: ISerializedImagePreviewInput = JSON.parse(serializedEditor);
|
||||
return instantiationService.createInstance(ImagePreviewEditorInput, URI.revive(data.resource), URI.revive(data.preferredResource));
|
||||
}
|
||||
}
|
||||
|
||||
class MediaPlayerEditorInputSerializer implements IEditorSerializer {
|
||||
|
||||
canSerialize(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
serialize(input: MediaPlayerEditorInput): string {
|
||||
return JSON.stringify({ kind: input.kind, resource: input.resource.toJSON() } satisfies ISerializedMediaPlayerInput);
|
||||
}
|
||||
|
||||
deserialize(instantiationService: IInstantiationService, serializedEditor: string): MediaPlayerEditorInput | undefined {
|
||||
const data: ISerializedMediaPlayerInput = JSON.parse(serializedEditor);
|
||||
if (!isMediaPlayerKind(data.kind)) {
|
||||
return undefined;
|
||||
}
|
||||
return MediaPlayerEditorInput.create(instantiationService, data.kind, URI.revive(data.resource), undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const editorFactoryRegistry = Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory);
|
||||
editorFactoryRegistry.registerEditorSerializer(ImagePreviewEditorInput.ID, ImagePreviewEditorInputSerializer);
|
||||
editorFactoryRegistry.registerEditorSerializer(MediaPlayerEditorInput.typeId, MediaPlayerEditorInputSerializer);
|
||||
|
||||
// --- Editor Resolver ---
|
||||
|
||||
class MediaPreviewEditorResolverContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
static readonly ID = 'workbench.contrib.mediaPreviewEditorResolver';
|
||||
|
||||
constructor(
|
||||
@IEditorResolverService editorResolverService: IEditorResolverService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
) {
|
||||
super();
|
||||
|
||||
const createDiffEditorInput = ({ original, modified, label, description }: IResourceDiffEditorInput, createInput: (resource: URI) => EditorInput): EditorInputWithOptions => {
|
||||
if (!original.resource || !modified.resource) {
|
||||
throw new Error('Media previews require a resource on both sides of a diff');
|
||||
}
|
||||
|
||||
// Show both sides next to each other in the side by side editor
|
||||
return { editor: instantiationService.createInstance(DiffEditorInput, label, description, createInput(original.resource), createInput(modified.resource), true) };
|
||||
};
|
||||
|
||||
const createImageInput = (resource: URI) => instantiationService.createInstance(ImagePreviewEditorInput, resource, undefined);
|
||||
this._register(editorResolverService.registerEditor(
|
||||
IMAGE_PREVIEW_FILE_PATTERN,
|
||||
{
|
||||
id: IMAGE_PREVIEW_EDITOR_ID,
|
||||
label: localize('imagePreview.displayName', "Image Preview"),
|
||||
detail: DEFAULT_EDITOR_ASSOCIATION.providerDisplayName,
|
||||
priority: RegisteredEditorPriority.builtin,
|
||||
},
|
||||
{},
|
||||
{
|
||||
createEditorInput: ({ resource }) => ({ editor: createImageInput(resource) }),
|
||||
createDiffEditorInput: diffEditorInput => createDiffEditorInput(diffEditorInput, createImageInput),
|
||||
}
|
||||
));
|
||||
|
||||
const registerMediaPlayer = (kind: MediaPlayerKind, filePattern: string, id: string, label: string) => {
|
||||
this._register(editorResolverService.registerEditor(
|
||||
filePattern,
|
||||
{
|
||||
id,
|
||||
label,
|
||||
detail: DEFAULT_EDITOR_ASSOCIATION.providerDisplayName,
|
||||
priority: RegisteredEditorPriority.builtin,
|
||||
},
|
||||
{},
|
||||
{
|
||||
createEditorInput: ({ resource }, group) => ({ editor: MediaPlayerEditorInput.create(instantiationService, kind, resource, group.id) }),
|
||||
createDiffEditorInput: (diffEditorInput, group) => createDiffEditorInput(diffEditorInput, resource => MediaPlayerEditorInput.create(instantiationService, kind, resource, group.id)),
|
||||
}
|
||||
));
|
||||
};
|
||||
registerMediaPlayer('video', VIDEO_PREVIEW_FILE_PATTERN, VIDEO_PREVIEW_EDITOR_ID, localize('videoPreview.displayName', "Video Preview"));
|
||||
registerMediaPlayer('audio', AUDIO_PREVIEW_FILE_PATTERN, AUDIO_PREVIEW_EDITOR_ID, localize('audioPreview.displayName', "Audio Preview"));
|
||||
}
|
||||
}
|
||||
|
||||
registerWorkbenchContribution2(MediaPreviewEditorResolverContribution.ID, MediaPreviewEditorResolverContribution, WorkbenchPhase.BlockStartup);
|
||||
|
||||
// --- Migration of editors restored from previous sessions ---
|
||||
|
||||
const legacyMediaPreviewViewTypes = new Set([IMAGE_PREVIEW_EDITOR_ID, VIDEO_PREVIEW_EDITOR_ID, AUDIO_PREVIEW_EDITOR_ID]);
|
||||
|
||||
function isLegacyMediaPreview(editor: EditorInput): editor is CustomEditorInput {
|
||||
return editor instanceof CustomEditorInput && legacyMediaPreviewViewTypes.has(editor.viewType);
|
||||
}
|
||||
|
||||
/**
|
||||
* The image, video and audio previews used to be custom editors contributed by the `media-preview`
|
||||
* extension. Editors of those types that were restored from a previous session can no longer be
|
||||
* resolved, so replace them with the built-in previews.
|
||||
*/
|
||||
class MediaPreviewLegacyEditorMigration extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
static readonly ID = 'workbench.contrib.mediaPreviewLegacyEditorMigration';
|
||||
|
||||
constructor(
|
||||
@IEditorService editorService: IEditorService,
|
||||
@IEditorGroupsService editorGroupsService: IEditorGroupsService,
|
||||
) {
|
||||
super();
|
||||
|
||||
const replacements = new Map<number, IUntypedEditorReplacement[]>();
|
||||
for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) {
|
||||
const group = editorGroupsService.getGroup(groupId);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = { pinned: group.isPinned(editor), sticky: group.isSticky(editor), preserveFocus: true };
|
||||
let replacement: IUntypedEditorReplacement['replacement'] | undefined;
|
||||
if (isLegacyMediaPreview(editor)) {
|
||||
replacement = { resource: editor.resource, options: { ...options, override: editor.viewType } };
|
||||
} else if (editor instanceof DiffEditorInput && isLegacyMediaPreview(editor.modified) && isLegacyMediaPreview(editor.original)) {
|
||||
replacement = {
|
||||
original: { resource: editor.original.resource },
|
||||
modified: { resource: editor.modified.resource },
|
||||
label: editor.getName(),
|
||||
description: editor.getDescription(),
|
||||
options: { ...options, override: editor.modified.viewType },
|
||||
};
|
||||
}
|
||||
|
||||
if (replacement) {
|
||||
const groupReplacements = replacements.get(groupId) ?? [];
|
||||
groupReplacements.push({ editor, replacement });
|
||||
replacements.set(groupId, groupReplacements);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [groupId, groupReplacements] of replacements) {
|
||||
void editorService.replaceEditors(groupReplacements, groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerWorkbenchContribution2(MediaPreviewLegacyEditorMigration.ID, MediaPreviewLegacyEditorMigration, WorkbenchPhase.AfterRestored);
|
||||
|
||||
// --- Status Bar ---
|
||||
|
||||
registerWorkbenchContribution2(MediaPreviewStatusBarContribution.ID, MediaPreviewStatusBarContribution, WorkbenchPhase.AfterRestored);
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
registerWorkbenchContribution2(ActiveImagePreviewContextKeyContribution.ID, ActiveImagePreviewContextKeyContribution, WorkbenchPhase.AfterRestored);
|
||||
|
||||
registerAction2(ZoomInImageAction);
|
||||
registerAction2(ZoomOutImageAction);
|
||||
registerAction2(SelectImageZoomLevelAction);
|
||||
registerAction2(CopyImageAction);
|
||||
registerAction2(ReopenImageAsTextAction);
|
||||
registerAction2(ReopenImageAsPreviewAction);
|
||||
registerImageViewerCopyCommand();
|
||||
@@ -0,0 +1,191 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { IDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { CopyAction } from '../../../../editor/contrib/clipboard/browser/clipboard.js';
|
||||
import { localize, localize2 } from '../../../../nls.js';
|
||||
import { Action2, MenuId } from '../../../../platform/actions/common/actions.js';
|
||||
import { ICommandService } from '../../../../platform/commands/common/commands.js';
|
||||
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
|
||||
import { REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID } from '../../../browser/parts/editor/editorCommands.js';
|
||||
import { ActiveEditorContext, ResourceContextKey } from '../../../common/contextkeys.js';
|
||||
import { DEFAULT_EDITOR_ASSOCIATION } from '../../../common/editor.js';
|
||||
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
||||
import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js';
|
||||
import { IMAGE_PREVIEW_EDITOR_ID } from '../common/mediaPreview.js';
|
||||
import { ActiveImagePreviewContext, getActiveInnerEditorPane } from './activeMediaPreview.js';
|
||||
import { ImagePreviewEditor } from './imagePreviewEditor.js';
|
||||
import { ImageViewer, ImageViewerContextMenuId, ImageViewerScale, isImageViewerEditorPane } from './imageViewer.js';
|
||||
import { getImageZoomLabel, SELECT_IMAGE_ZOOM_LEVEL_COMMAND_ID } from './mediaPreviewStatusBar.js';
|
||||
|
||||
const imagePreviewCategory = localize2('imagePreview', "Image Preview");
|
||||
|
||||
const imagePreviewActive = ActiveEditorContext.isEqualTo(ImagePreviewEditor.ID);
|
||||
const svgImagePreviewActive = ContextKeyExpr.and(imagePreviewActive, ResourceContextKey.Extension.isEqualTo('.svg'));
|
||||
const svgTextEditorActive = ContextKeyExpr.and(
|
||||
ActiveEditorContext.isEqualTo(TEXT_FILE_EDITOR_ID),
|
||||
ResourceContextKey.Extension.isEqualTo('.svg'),
|
||||
// Allows extensions that provide their own SVG preview to hide this action
|
||||
ContextKeyExpr.not('hasCustomImagePreview'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the viewer an image action applies to: the viewer passed by the image context menu, the
|
||||
* focused viewer, or the viewer of the active editor. For diffs, that is the viewer of the side
|
||||
* that was focused last, which is also the side the status bar shows.
|
||||
*/
|
||||
function getImageViewer(accessor: ServicesAccessor, arg: unknown): ImageViewer | undefined {
|
||||
if (arg instanceof ImageViewer) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
const focusedViewer = ImageViewer.getFocusedViewer();
|
||||
if (focusedViewer) {
|
||||
return focusedViewer;
|
||||
}
|
||||
|
||||
const activeEditorPane = getActiveInnerEditorPane(accessor.get(IEditorService));
|
||||
return isImageViewerEditorPane(activeEditorPane) ? activeEditorPane.imageViewer : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the workbench copy command (e.g. Cmd/Ctrl+C) copy the image of a focused image viewer.
|
||||
*/
|
||||
export function registerImageViewerCopyCommand(): IDisposable | undefined {
|
||||
return CopyAction?.addImplementation(100, 'image-viewer', () => {
|
||||
const viewer = ImageViewer.getFocusedViewer();
|
||||
if (!viewer?.hasImage) {
|
||||
return false;
|
||||
}
|
||||
return viewer.copyImage();
|
||||
});
|
||||
}
|
||||
|
||||
export class ZoomInImageAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'imagePreview.zoomIn',
|
||||
title: localize2('imagePreview.zoomIn', "Zoom In"),
|
||||
category: imagePreviewCategory,
|
||||
f1: true,
|
||||
precondition: ActiveImagePreviewContext,
|
||||
});
|
||||
}
|
||||
|
||||
run(accessor: ServicesAccessor, arg?: unknown): void {
|
||||
getImageViewer(accessor, arg)?.zoomIn();
|
||||
}
|
||||
}
|
||||
|
||||
export class ZoomOutImageAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'imagePreview.zoomOut',
|
||||
title: localize2('imagePreview.zoomOut', "Zoom Out"),
|
||||
category: imagePreviewCategory,
|
||||
f1: true,
|
||||
precondition: ActiveImagePreviewContext,
|
||||
});
|
||||
}
|
||||
|
||||
run(accessor: ServicesAccessor, arg?: unknown): void {
|
||||
getImageViewer(accessor, arg)?.zoomOut();
|
||||
}
|
||||
}
|
||||
|
||||
export class SelectImageZoomLevelAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: SELECT_IMAGE_ZOOM_LEVEL_COMMAND_ID,
|
||||
title: localize2('imagePreview.selectZoomLevel', "Select Zoom Level"),
|
||||
category: imagePreviewCategory,
|
||||
f1: true,
|
||||
precondition: ActiveImagePreviewContext,
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor, arg?: unknown): Promise<void> {
|
||||
const viewer = getImageViewer(accessor, arg);
|
||||
if (!viewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scales: ImageViewerScale[] = [10, 5, 2, 1, 0.5, 0.2, 'fit'];
|
||||
const pick = await accessor.get(IQuickInputService).pick<IQuickPickItem & { scale: ImageViewerScale }>(
|
||||
scales.map(scale => ({ label: getImageZoomLabel(scale), scale })),
|
||||
{ placeHolder: localize('imagePreview.selectZoomLevelPlaceholder', "Select zoom level") },
|
||||
);
|
||||
if (pick) {
|
||||
viewer.setScale(pick.scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CopyImageAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'imagePreview.copyImage',
|
||||
title: localize2('imagePreview.copyImage', "Copy"),
|
||||
category: imagePreviewCategory,
|
||||
f1: false,
|
||||
menu: {
|
||||
id: ImageViewerContextMenuId,
|
||||
group: 'navigation',
|
||||
order: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
run(accessor: ServicesAccessor, arg?: unknown): Promise<void> | undefined {
|
||||
return getImageViewer(accessor, arg)?.copyImage();
|
||||
}
|
||||
}
|
||||
|
||||
export class ReopenImageAsTextAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'imagePreview.reopenAsText',
|
||||
title: localize2('imagePreview.reopenAsText', "Reopen as Source Text"),
|
||||
category: imagePreviewCategory,
|
||||
icon: Codicon.goToFile,
|
||||
f1: true,
|
||||
precondition: svgImagePreviewActive,
|
||||
menu: {
|
||||
id: MenuId.EditorTitle,
|
||||
group: 'navigation',
|
||||
when: svgImagePreviewActive,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
run(accessor: ServicesAccessor, ...args: unknown[]): Promise<unknown> {
|
||||
return accessor.get(ICommandService).executeCommand(REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID, DEFAULT_EDITOR_ASSOCIATION.id, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReopenImageAsPreviewAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'imagePreview.reopenAsPreview',
|
||||
title: localize2('imagePreview.reopenAsPreview', "Reopen as Image Preview"),
|
||||
category: imagePreviewCategory,
|
||||
icon: Codicon.preview,
|
||||
f1: true,
|
||||
precondition: svgTextEditorActive,
|
||||
menu: {
|
||||
id: MenuId.EditorTitle,
|
||||
group: 'navigation',
|
||||
when: svgTextEditorActive,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
run(accessor: ServicesAccessor, ...args: unknown[]): Promise<unknown> {
|
||||
return accessor.get(ICommandService).executeCommand(REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID, IMAGE_PREVIEW_EDITOR_ID, ...args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { DEFAULT_EDITOR_ASSOCIATION, EditorsOrder } from '../../../common/editor.js';
|
||||
import { DiffEditorInput } from '../../../common/editor/diffEditorInput.js';
|
||||
import { EditorInput } from '../../../common/editor/editorInput.js';
|
||||
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
||||
|
||||
/**
|
||||
* Replaces a media preview with the default text editor in all groups it is opened in. When the
|
||||
* preview is shown as part of a diff, the resource is opened in a separate text editor instead,
|
||||
* since a text diff of binary content falls back to the media diff again.
|
||||
*/
|
||||
export async function reopenInTextEditor(input: EditorInput, editorService: IEditorService): Promise<void> {
|
||||
const resource = input.resource;
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = { override: DEFAULT_EDITOR_ASSOCIATION.id, pinned: true };
|
||||
for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) {
|
||||
if (editor === input) {
|
||||
await editorService.replaceEditors([{ editor, replacement: { resource, options } }], groupId);
|
||||
} else if (editor instanceof DiffEditorInput && (editor.original === input || editor.modified === input)) {
|
||||
await editorService.openEditor({ resource, options }, groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { isEqual } from '../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { IFileService } from '../../../../platform/files/common/files.js';
|
||||
import { ISaveOptions } from '../../../common/editor.js';
|
||||
|
||||
/**
|
||||
* Implements Save As for media previews, which can't modify the file: asks for a destination and
|
||||
* copies the file there.
|
||||
*
|
||||
* @returns The destination, or `undefined` if the user cancelled.
|
||||
*/
|
||||
export async function saveMediaFileAs(resource: URI, fileService: IFileService, fileDialogService: IFileDialogService, options: ISaveOptions | undefined): Promise<URI | undefined> {
|
||||
const target = await fileDialogService.pickFileToSave(resource, options?.availableFileSystems);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isEqual(target, resource)) {
|
||||
// The save dialog already confirmed replacing an existing file
|
||||
await fileService.copy(resource, target, true);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { ByteSize } from '../../../../platform/files/common/files.js';
|
||||
import { IWorkbenchContribution } from '../../../common/contributions.js';
|
||||
import { IEditorPane } from '../../../common/editor.js';
|
||||
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
||||
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js';
|
||||
import { onDidChangeActiveInnerEditorPane } from './activeMediaPreview.js';
|
||||
import { ImagePreviewEditor } from './imagePreviewEditor.js';
|
||||
import { ImageViewerScale } from './imageViewer.js';
|
||||
import { MediaPlayerEditorInput } from './mediaPlayerEditorInput.js';
|
||||
|
||||
export const SELECT_IMAGE_ZOOM_LEVEL_COMMAND_ID = 'imagePreview.selectZoomLevel';
|
||||
|
||||
export function getImageZoomLabel(scale: ImageViewerScale): string {
|
||||
return scale === 'fit'
|
||||
? localize('imagePreview.zoomFit', "Whole Image")
|
||||
: localize('imagePreview.zoomPercent', "{0}%", Math.round(scale * 100));
|
||||
}
|
||||
|
||||
interface IMediaPreviewStatus {
|
||||
readonly scale?: ImageViewerScale;
|
||||
readonly dimensions?: { readonly width: number; readonly height: number };
|
||||
readonly fileSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the zoom level, dimensions and file size of the active image, video or audio preview in
|
||||
* the status bar.
|
||||
*/
|
||||
export class MediaPreviewStatusBarContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
static readonly ID = 'workbench.contrib.mediaPreviewStatusBar';
|
||||
|
||||
private readonly _zoomEntry = this._register(new MutableDisposable<IStatusbarEntryAccessor>());
|
||||
private readonly _dimensionsEntry = this._register(new MutableDisposable<IStatusbarEntryAccessor>());
|
||||
private readonly _fileSizeEntry = this._register(new MutableDisposable<IStatusbarEntryAccessor>());
|
||||
private readonly _paneListener = this._register(new MutableDisposable());
|
||||
|
||||
constructor(
|
||||
@IEditorService editorService: IEditorService,
|
||||
@IStatusbarService private readonly _statusbarService: IStatusbarService,
|
||||
) {
|
||||
super();
|
||||
|
||||
// For diffs, show the status of the side that was focused last
|
||||
this._register(onDidChangeActiveInnerEditorPane(editorService, pane => this._showStatusOf(pane)));
|
||||
}
|
||||
|
||||
private _showStatusOf(pane: IEditorPane | undefined): void {
|
||||
if (pane instanceof ImagePreviewEditor) {
|
||||
const update = () => {
|
||||
const viewer = pane.imageViewer;
|
||||
this._update({
|
||||
scale: viewer?.hasImage ? viewer.scale : undefined,
|
||||
dimensions: viewer?.naturalSize,
|
||||
fileSize: pane.fileSize,
|
||||
});
|
||||
};
|
||||
this._paneListener.value = pane.onDidChangeImageInfo(update);
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
const input = pane?.input;
|
||||
if (input instanceof MediaPlayerEditorInput) {
|
||||
const update = () => this._update({ fileSize: input.fileSize });
|
||||
this._paneListener.value = input.onDidChangeFileSize(update);
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
this._paneListener.clear();
|
||||
this._update({});
|
||||
}
|
||||
|
||||
private _update(status: IMediaPreviewStatus): void {
|
||||
const zoomLabel = status.scale !== undefined ? getImageZoomLabel(status.scale) : undefined;
|
||||
this._updateEntry(this._zoomEntry, 'status.imagePreview.zoom', 102 /* to the left of the dimensions entry */, zoomLabel === undefined ? undefined : {
|
||||
name: localize('imagePreview.zoomStatus', "Image Zoom"),
|
||||
text: zoomLabel,
|
||||
ariaLabel: localize('imagePreview.zoomStatusAriaLabel', "Image Zoom: {0}", zoomLabel),
|
||||
tooltip: localize('imagePreview.selectZoomLevel', "Select Zoom Level"),
|
||||
command: SELECT_IMAGE_ZOOM_LEVEL_COMMAND_ID,
|
||||
});
|
||||
|
||||
const dimensions = status.dimensions ? `${status.dimensions.width}x${status.dimensions.height}` : undefined;
|
||||
this._updateEntry(this._dimensionsEntry, 'status.imagePreview.dimensions', 101 /* to the left of the file size entry */, dimensions === undefined ? undefined : {
|
||||
name: localize('imagePreview.dimensionsStatus', "Image Dimensions"),
|
||||
text: dimensions,
|
||||
ariaLabel: localize('imagePreview.dimensionsStatusAriaLabel', "Image Dimensions: {0}", dimensions),
|
||||
});
|
||||
|
||||
const fileSize = typeof status.fileSize === 'number' ? ByteSize.formatSize(status.fileSize) : undefined;
|
||||
this._updateEntry(this._fileSizeEntry, 'status.mediaPreview.fileSize', 100, fileSize === undefined ? undefined : {
|
||||
name: localize('mediaPreview.fileSizeStatus', "Media File Size"),
|
||||
text: fileSize,
|
||||
ariaLabel: localize('mediaPreview.fileSizeStatusAriaLabel', "File Size: {0}", fileSize),
|
||||
});
|
||||
}
|
||||
|
||||
private _updateEntry(entry: MutableDisposable<IStatusbarEntryAccessor>, id: string, priority: number, props: IStatusbarEntry | undefined): void {
|
||||
if (!props) {
|
||||
entry.clear();
|
||||
} else if (entry.value) {
|
||||
entry.value.update(props);
|
||||
} else {
|
||||
entry.value = this._statusbarService.addEntry(props, id, StatusbarAlignment.RIGHT, priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Schemas } from '../../../../base/common/network.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { IFileService } from '../../../../platform/files/common/files.js';
|
||||
|
||||
/**
|
||||
* Editor resolver id of the image preview. Kept identical to the view type of the image preview
|
||||
* custom editor that previously shipped in the `media-preview` extension so that existing
|
||||
* `workbench.editorAssociations` and `vscode.openWith` callers continue to work.
|
||||
*/
|
||||
export const IMAGE_PREVIEW_EDITOR_ID = 'imagePreview.previewEditor';
|
||||
|
||||
/**
|
||||
* Editor resolver id of the video preview. Kept identical to the view type of the video preview
|
||||
* custom editor that previously shipped in the `media-preview` extension.
|
||||
*/
|
||||
export const VIDEO_PREVIEW_EDITOR_ID = 'vscode.videoPreview';
|
||||
|
||||
/**
|
||||
* Editor resolver id of the audio preview. Kept identical to the view type of the audio preview
|
||||
* custom editor that previously shipped in the `media-preview` extension.
|
||||
*/
|
||||
export const AUDIO_PREVIEW_EDITOR_ID = 'vscode.audioPreview';
|
||||
|
||||
export const IMAGE_PREVIEW_FILE_PATTERN = '*.{jpg,jpe,jpeg,png,bmp,gif,ico,webp,avif,svg}';
|
||||
export const VIDEO_PREVIEW_FILE_PATTERN = '*.{mp4,webm}';
|
||||
export const AUDIO_PREVIEW_FILE_PATTERN = '*.{mp3,wav,ogg,oga}';
|
||||
|
||||
export const VIDEO_PREVIEW_AUTO_PLAY_SETTING = 'mediaPreview.video.autoPlay';
|
||||
export const VIDEO_PREVIEW_LOOP_SETTING = 'mediaPreview.video.loop';
|
||||
|
||||
const gitLfsPointerPrefix = 'version https://git-lfs.github.com/spec/v1';
|
||||
const gitLfsPointerMaxSize = 1024;
|
||||
|
||||
/**
|
||||
* Checks if a resource from the git extension is a Git LFS pointer file instead of the actual
|
||||
* media file. Such pointers are small text files that can't be previewed.
|
||||
*/
|
||||
export async function isGitLfsPointer(fileService: IFileService, resource: URI): Promise<boolean> {
|
||||
if (resource.scheme !== Schemas.git) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fileService.stat(resource);
|
||||
if (stat.size === 0 || stat.size > gitLfsPointerMaxSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = await fileService.readFile(resource);
|
||||
return content.value.toString().startsWith(gitLfsPointerPrefix);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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 assert from 'assert';
|
||||
import { Emitter } from '../../../../../base/common/event.js';
|
||||
import { upcastPartial } from '../../../../../base/test/common/mock.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
|
||||
import { SideBySideEditor } from '../../../../browser/parts/editor/sideBySideEditor.js';
|
||||
import { IEditorPane, IVisibleEditorPane } from '../../../../common/editor.js';
|
||||
import { IEditorService } from '../../../../services/editor/common/editorService.js';
|
||||
import { ActiveImagePreviewContext, ActiveImagePreviewContextKeyContribution } from '../../browser/activeMediaPreview.js';
|
||||
import { ImagePreviewEditor } from '../../browser/imagePreviewEditor.js';
|
||||
|
||||
suite('Media Preview - ActiveImagePreviewContext', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('follows the focused side of diffs', () => {
|
||||
const onDidActiveEditorChange = store.add(new Emitter<void>());
|
||||
const onDidChangeControl = store.add(new Emitter<void>());
|
||||
const imagePane = Object.create(ImagePreviewEditor.prototype) as ImagePreviewEditor;
|
||||
const videoPane = upcastPartial<IEditorPane>({});
|
||||
let focusedSide: IEditorPane | undefined = imagePane;
|
||||
const diffPane = Object.assign(Object.create(SideBySideEditor.prototype), {
|
||||
onDidChangeControl: onDidChangeControl.event,
|
||||
getLastFocusedEditorPane: () => focusedSide,
|
||||
}) as SideBySideEditor;
|
||||
|
||||
let activeEditorPane: IVisibleEditorPane | undefined = imagePane as IVisibleEditorPane;
|
||||
const editorService = upcastPartial<IEditorService>({
|
||||
get activeEditorPane() { return activeEditorPane; },
|
||||
onDidActiveEditorChange: onDidActiveEditorChange.event,
|
||||
});
|
||||
const contextKeyService = new MockContextKeyService();
|
||||
store.add(new ActiveImagePreviewContextKeyContribution(editorService, contextKeyService));
|
||||
const isActive = () => contextKeyService.getContextKeyValue(ActiveImagePreviewContext.key);
|
||||
|
||||
const states: Record<string, unknown> = { standaloneImage: isActive() };
|
||||
|
||||
activeEditorPane = diffPane as IVisibleEditorPane;
|
||||
onDidActiveEditorChange.fire();
|
||||
states.diffFocusedOnImage = isActive();
|
||||
|
||||
focusedSide = videoPane;
|
||||
onDidChangeControl.fire();
|
||||
states.diffFocusedOnVideo = isActive();
|
||||
|
||||
activeEditorPane = undefined;
|
||||
onDidActiveEditorChange.fire();
|
||||
focusedSide = imagePane;
|
||||
onDidChangeControl.fire();
|
||||
states.noEditorAfterDiffClosed = isActive();
|
||||
|
||||
assert.deepStrictEqual(states, {
|
||||
standaloneImage: true,
|
||||
diffFocusedOnImage: true,
|
||||
diffFocusedOnVideo: false,
|
||||
noEditorAfterDiffClosed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { $ } from '../../../../../base/browser/dom.js';
|
||||
import { mainWindow } from '../../../../../base/browser/window.js';
|
||||
import { DeferredPromise } from '../../../../../base/common/async.js';
|
||||
import { Emitter, Event } from '../../../../../base/common/event.js';
|
||||
import { VSBuffer } from '../../../../../base/common/buffer.js';
|
||||
import { CancellationToken } from '../../../../../base/common/cancellation.js';
|
||||
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
|
||||
import { ResourceMap } from '../../../../../base/common/map.js';
|
||||
import { Schemas } from '../../../../../base/common/network.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';
|
||||
import { TestClipboardService } from '../../../../../platform/clipboard/test/common/testClipboardService.js';
|
||||
import { FileChangesEvent, FileChangeType, IFileContent, IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js';
|
||||
import { TestEditorGroupView, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';
|
||||
import { TestFileService } from '../../../../test/common/workbenchTestServices.js';
|
||||
import { mock } from '../../../../../base/test/common/mock.js';
|
||||
import { IWebWorkerService } from '../../../../../platform/webWorker/browser/webWorkerService.js';
|
||||
import { ImagePreviewEditor } from '../../browser/imagePreviewEditor.js';
|
||||
import { ImagePreviewEditorInput } from '../../browser/imagePreviewEditorInput.js';
|
||||
|
||||
interface ImagePreviewEditorTestAccessor {
|
||||
_reloadImage(input: ImagePreviewEditorInput, initialViewState: undefined): Promise<void>;
|
||||
}
|
||||
|
||||
function createPng(width: number, height: number): VSBuffer {
|
||||
const canvas = $<HTMLCanvasElement>('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d')!.fillRect(0, 0, width, height);
|
||||
const base64 = canvas.toDataURL('image/png').split(',')[1];
|
||||
return VSBuffer.wrap(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the editor shows an image with the given width.
|
||||
*/
|
||||
function whenImageWidth(editor: ImagePreviewEditor, width: number, disposables: DisposableStore): Promise<void> {
|
||||
return Event.toPromise(Event.filter(editor.onDidChangeImageInfo, () => editor.imageViewer?.naturalSize?.width === width), disposables);
|
||||
}
|
||||
|
||||
suite('Media Preview - ImagePreviewEditor', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('a pending reload does not replace a newer image', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
instantiationService.stub(IClipboardService, new TestClipboardService());
|
||||
instantiationService.stub(IWebWorkerService, new class extends mock<IWebWorkerService>() { }());
|
||||
|
||||
const imageA = URI.file('/images/a.png');
|
||||
const imageB = URI.file('/images/b.png');
|
||||
const contents = new ResourceMap<VSBuffer>([[imageA, createPng(40, 20)], [imageB, createPng(10, 10)]]);
|
||||
let pendingRead: DeferredPromise<IFileContent> | undefined;
|
||||
const fileService = instantiationService.get(IFileService);
|
||||
fileService.readFile = async resource => {
|
||||
if (pendingRead) {
|
||||
const read = pendingRead;
|
||||
pendingRead = undefined;
|
||||
return read.p;
|
||||
}
|
||||
const value = contents.get(resource)!;
|
||||
return { resource, value, size: value.byteLength } as IFileContent;
|
||||
};
|
||||
|
||||
const container = $('div');
|
||||
container.style.width = '400px';
|
||||
container.style.height = '400px';
|
||||
mainWindow.document.body.appendChild(container);
|
||||
disposables.add({ dispose: () => container.remove() });
|
||||
|
||||
const editor = disposables.add(instantiationService.createInstance(ImagePreviewEditor, new TestEditorGroupView(1)));
|
||||
editor.create(container);
|
||||
const inputA = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, imageA, undefined));
|
||||
const inputB = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, imageB, undefined));
|
||||
|
||||
await editor.setInput(inputA, undefined, {}, CancellationToken.None);
|
||||
|
||||
// Start reloading image A, then switch to image B before the reload's read completes
|
||||
pendingRead = new DeferredPromise<IFileContent>();
|
||||
const staleRead = pendingRead;
|
||||
const reload = (editor as unknown as ImagePreviewEditorTestAccessor)._reloadImage(inputA, undefined);
|
||||
await editor.setInput(inputB, undefined, {}, CancellationToken.None);
|
||||
const imageAContent = contents.get(imageA)!;
|
||||
await staleRead.complete({ resource: imageA, value: imageAContent, size: imageAContent.byteLength } as IFileContent);
|
||||
await reload;
|
||||
|
||||
assert.deepStrictEqual({
|
||||
input: editor.input?.resource?.toString(),
|
||||
naturalSize: editor.imageViewer?.naturalSize,
|
||||
fileSize: editor.fileSize,
|
||||
}, {
|
||||
input: imageB.toString(),
|
||||
naturalSize: { width: 10, height: 10 },
|
||||
fileSize: contents.get(imageB)!.byteLength,
|
||||
});
|
||||
});
|
||||
|
||||
test('reloads an image that changes while it is loading', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
instantiationService.stub(IClipboardService, new TestClipboardService());
|
||||
instantiationService.stub(IWebWorkerService, new class extends mock<IWebWorkerService>() { }());
|
||||
|
||||
const image = URI.file('/images/generated.png');
|
||||
const versions = [createPng(40, 20), createPng(10, 10)];
|
||||
const initialRead = new DeferredPromise<IFileContent>();
|
||||
const initialReadStarted = new DeferredPromise<void>();
|
||||
let reads = 0;
|
||||
const onDidChange = disposables.add(new Emitter<FileChangesEvent>());
|
||||
const fileService = instantiationService.get(IFileService);
|
||||
fileService.createWatcher = () => ({ onDidChange: onDidChange.event, dispose: () => { } });
|
||||
fileService.readFile = async resource => {
|
||||
if (reads++ === 0) {
|
||||
initialReadStarted.complete();
|
||||
return initialRead.p;
|
||||
}
|
||||
return { resource, value: versions[1], size: versions[1].byteLength } as IFileContent;
|
||||
};
|
||||
|
||||
const container = $('div');
|
||||
mainWindow.document.body.appendChild(container);
|
||||
disposables.add({ dispose: () => container.remove() });
|
||||
|
||||
const editor = disposables.add(instantiationService.createInstance(ImagePreviewEditor, new TestEditorGroupView(1)));
|
||||
editor.create(container);
|
||||
const input = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, image, undefined));
|
||||
|
||||
const reloaded = whenImageWidth(editor, 10, disposables);
|
||||
|
||||
// The file changes after the initial read started but before it completed
|
||||
const setInput = editor.setInput(input, undefined, {}, CancellationToken.None);
|
||||
await initialReadStarted.p;
|
||||
onDidChange.fire(new FileChangesEvent([{ resource: image, type: FileChangeType.UPDATED }], false));
|
||||
await initialRead.complete({ resource: image, value: versions[0], size: versions[0].byteLength } as IFileContent);
|
||||
await setInput;
|
||||
await reloaded;
|
||||
|
||||
assert.deepStrictEqual({ reads, naturalSize: editor.imageViewer?.naturalSize }, { reads: 2, naturalSize: { width: 10, height: 10 } });
|
||||
});
|
||||
|
||||
test('reloads an image on changes that are not reported to its watcher', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
instantiationService.stub(IClipboardService, new TestClipboardService());
|
||||
instantiationService.stub(IWebWorkerService, new class extends mock<IWebWorkerService>() { }());
|
||||
|
||||
// Extension file systems report changes to `IFileService.onDidFilesChange` only
|
||||
const image = URI.from({ scheme: 'extension-fs', path: '/images/generated.png' });
|
||||
const versions = [createPng(40, 20), createPng(10, 10)];
|
||||
let reads = 0;
|
||||
const fileService = instantiationService.get(IFileService) as TestFileService;
|
||||
fileService.readFile = async resource => {
|
||||
const value = versions[Math.min(reads++, 1)];
|
||||
return { resource, value, size: value.byteLength } as IFileContent;
|
||||
};
|
||||
|
||||
const container = $('div');
|
||||
mainWindow.document.body.appendChild(container);
|
||||
disposables.add({ dispose: () => container.remove() });
|
||||
|
||||
const editor = disposables.add(instantiationService.createInstance(ImagePreviewEditor, new TestEditorGroupView(1)));
|
||||
editor.create(container);
|
||||
const input = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, image, undefined));
|
||||
await editor.setInput(input, undefined, {}, CancellationToken.None);
|
||||
|
||||
const reloaded = whenImageWidth(editor, 10, disposables);
|
||||
fileService.fireFileChanges(new FileChangesEvent([{ resource: image, type: FileChangeType.UPDATED }], false));
|
||||
await reloaded;
|
||||
|
||||
assert.deepStrictEqual({ reads, naturalSize: editor.imageViewer?.naturalSize }, { reads: 2, naturalSize: { width: 10, height: 10 } });
|
||||
});
|
||||
|
||||
test('copies the displayed image even if the file changed since', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
const clipboardService = new TestClipboardService();
|
||||
instantiationService.stub(IClipboardService, clipboardService);
|
||||
instantiationService.stub(IWebWorkerService, new class extends mock<IWebWorkerService>() { }());
|
||||
|
||||
const image = URI.file('/images/generated.png');
|
||||
const displayed = createPng(40, 20);
|
||||
let current = displayed;
|
||||
const fileService = instantiationService.get(IFileService);
|
||||
fileService.readFile = async resource => ({ resource, value: current, size: current.byteLength } as IFileContent);
|
||||
|
||||
const container = $('div');
|
||||
mainWindow.document.body.appendChild(container);
|
||||
disposables.add({ dispose: () => container.remove() });
|
||||
|
||||
const editor = disposables.add(instantiationService.createInstance(ImagePreviewEditor, new TestEditorGroupView(1)));
|
||||
editor.create(container);
|
||||
const input = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, image, undefined));
|
||||
await editor.setInput(input, undefined, {}, CancellationToken.None);
|
||||
|
||||
// The file changes, but the preview hasn't reloaded yet
|
||||
current = createPng(10, 10);
|
||||
await editor.imageViewer?.copyImage();
|
||||
|
||||
const written = clipboardService.writtenImage;
|
||||
assert.strictEqual(!!written && VSBuffer.wrap(written).equals(displayed), true);
|
||||
});
|
||||
|
||||
test('shows Git LFS pointers as a message within the editor', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
instantiationService.stub(IClipboardService, new TestClipboardService());
|
||||
instantiationService.stub(IWebWorkerService, new class extends mock<IWebWorkerService>() { }());
|
||||
|
||||
const image = URI.from({ scheme: Schemas.git, path: '/images/stored-in-lfs.png' });
|
||||
const pointer = VSBuffer.fromString('version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345\n');
|
||||
const fileService = instantiationService.get(IFileService);
|
||||
fileService.stat = async resource => ({ resource, size: pointer.byteLength } as IFileStatWithMetadata);
|
||||
fileService.readFile = async resource => ({ resource, value: pointer, size: pointer.byteLength } as IFileContent);
|
||||
|
||||
const container = $('div');
|
||||
mainWindow.document.body.appendChild(container);
|
||||
disposables.add({ dispose: () => container.remove() });
|
||||
|
||||
const editor = disposables.add(instantiationService.createInstance(ImagePreviewEditor, new TestEditorGroupView(1)));
|
||||
editor.create(container);
|
||||
const input = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, image, undefined));
|
||||
await editor.setInput(input, undefined, {}, CancellationToken.None);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
hasImage: editor.imageViewer?.hasImage,
|
||||
showsMessage: container.querySelector('.image-preview-editor')?.classList.contains('has-message'),
|
||||
message: container.querySelector('.image-preview-message .editor-placeholder-label-container')?.textContent,
|
||||
}, {
|
||||
hasImage: false,
|
||||
showsMessage: true,
|
||||
message: 'The image is stored with Git LFS and is not available for preview.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { $ } from '../../../../../base/browser/dom.js';
|
||||
import { IAnchor } from '../../../../../base/browser/ui/contextview/contextview.js';
|
||||
import { mainWindow } from '../../../../../base/browser/window.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';
|
||||
import { TestClipboardService } from '../../../../../platform/clipboard/test/common/testClipboardService.js';
|
||||
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
|
||||
import { IContextMenuMenuDelegate, IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
|
||||
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
|
||||
import { IWebWorkerClient, Proxied } from '../../../../../base/common/worker/webWorker.js';
|
||||
import { mock } from '../../../../../base/test/common/mock.js';
|
||||
import { IWebWorkerService } from '../../../../../platform/webWorker/browser/webWorkerService.js';
|
||||
import { ImageEncoderWorker } from '../../browser/imageEncoderWorker.js';
|
||||
import { getNextZoomLevel, getPreviousZoomLevel, IImageViewerOptions, ImageViewer, ImageViewerContextMenuId, ImageViewerScale } from '../../browser/imageViewer.js';
|
||||
|
||||
function createImageDataUrl(width: number, height: number, type = 'image/png'): string {
|
||||
const canvas = $<HTMLCanvasElement>('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d')!;
|
||||
context.fillStyle = 'rgb(200, 50, 50)';
|
||||
context.fillRect(0, 0, width, height);
|
||||
return canvas.toDataURL(type);
|
||||
}
|
||||
|
||||
function toBytes(dataUrl: string): Uint8Array {
|
||||
return Uint8Array.from(atob(dataUrl.split(',')[1]), c => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
function getSignature(data: Uint8Array | undefined): number[] | undefined {
|
||||
return data && Array.from(data.subarray(0, pngSignature.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the image encoder worker's code in-process, or fails like an unavailable worker.
|
||||
*/
|
||||
class TestWebWorkerService extends mock<IWebWorkerService>() {
|
||||
created = 0;
|
||||
fail = false;
|
||||
|
||||
override createWorkerClient<T extends object>(): IWebWorkerClient<T> {
|
||||
this.created++;
|
||||
const encoder = new ImageEncoderWorker();
|
||||
const fail = this.fail;
|
||||
const client: Pick<IWebWorkerClient<ImageEncoderWorker>, 'dispose'> & { proxy: Pick<Proxied<ImageEncoderWorker>, '$encodePng'> } = {
|
||||
proxy: {
|
||||
$encodePng: bitmap => fail ? Promise.reject(new Error('Worker unavailable')) : encoder.$encodePng(bitmap),
|
||||
},
|
||||
dispose: () => { },
|
||||
};
|
||||
return client as unknown as IWebWorkerClient<T>;
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingClipboardService extends TestClipboardService {
|
||||
readonly events: string[] = [];
|
||||
|
||||
override writeImage(data: Uint8Array | Promise<Uint8Array>): Promise<void> {
|
||||
this.events.push('writeImage');
|
||||
return super.writeImage(data);
|
||||
}
|
||||
}
|
||||
|
||||
suite('ImageViewer', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
let container: HTMLElement;
|
||||
let contextMenuDelegates: IContextMenuMenuDelegate[];
|
||||
let clipboardService: RecordingClipboardService;
|
||||
let webWorkerService: TestWebWorkerService;
|
||||
|
||||
setup(() => {
|
||||
container = $('div');
|
||||
container.style.width = '400px';
|
||||
container.style.height = '400px';
|
||||
mainWindow.document.body.appendChild(container);
|
||||
contextMenuDelegates = [];
|
||||
clipboardService = new RecordingClipboardService();
|
||||
webWorkerService = new TestWebWorkerService();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function createViewer(options: IImageViewerOptions = {}): ImageViewer {
|
||||
const instantiationService = store.add(new TestInstantiationService());
|
||||
instantiationService.stub(IContextKeyService, new MockContextKeyService());
|
||||
instantiationService.stub(IClipboardService, clipboardService);
|
||||
instantiationService.stub(IWebWorkerService, webWorkerService);
|
||||
instantiationService.stub(IContextMenuService, {
|
||||
showContextMenu: (delegate: IContextMenuMenuDelegate) => {
|
||||
contextMenuDelegates.push(delegate);
|
||||
},
|
||||
});
|
||||
|
||||
const viewer = store.add(instantiationService.createInstance(ImageViewer, options));
|
||||
container.appendChild(viewer.element);
|
||||
return viewer;
|
||||
}
|
||||
|
||||
test('steps through the predefined zoom levels', () => {
|
||||
assert.deepStrictEqual({
|
||||
next: [0.05, 0.1, 0.95, 1, 2.5, 20, 25].map(getNextZoomLevel),
|
||||
previous: [0.05, 0.1, 0.95, 1, 2.5, 20, 25].map(getPreviousZoomLevel),
|
||||
}, {
|
||||
next: [0.1, 0.2, 1, 1.5, 3, 20, 20],
|
||||
previous: [0.1, 0.1, 0.9, 0.9, 2, 15, 20],
|
||||
});
|
||||
});
|
||||
|
||||
test('zooms from fit, pixelates large zoom levels, and resets to fit', async () => {
|
||||
const viewer = createViewer();
|
||||
const scales: ImageViewerScale[] = [];
|
||||
store.add(viewer.onDidChangeScale(scale => scales.push(scale)));
|
||||
|
||||
const loaded = await viewer.setSource(createImageDataUrl(40, 20), { alt: 'red' });
|
||||
const image = viewer.element.querySelector('img')!;
|
||||
const snapshot = () => ({
|
||||
scale: viewer.scale,
|
||||
zoomed: viewer.element.classList.contains('zoomed'),
|
||||
pixelated: image.classList.contains('pixelated'),
|
||||
});
|
||||
|
||||
const states = [snapshot()];
|
||||
viewer.zoomIn();
|
||||
states.push(snapshot());
|
||||
viewer.setScale(5);
|
||||
states.push(snapshot());
|
||||
viewer.setScale('fit');
|
||||
states.push(snapshot());
|
||||
viewer.zoomOut();
|
||||
states.push(snapshot());
|
||||
|
||||
assert.deepStrictEqual({ loaded, naturalSize: viewer.naturalSize, alt: image.alt, states, scales }, {
|
||||
loaded: true,
|
||||
naturalSize: { width: 40, height: 20 },
|
||||
alt: 'red',
|
||||
states: [
|
||||
{ scale: 'fit', zoomed: false, pixelated: false },
|
||||
{ scale: 1.5, zoomed: true, pixelated: false },
|
||||
{ scale: 5, zoomed: true, pixelated: true },
|
||||
{ scale: 'fit', zoomed: false, pixelated: false },
|
||||
{ scale: 0.9, zoomed: true, pixelated: false },
|
||||
],
|
||||
scales: [1.5, 5, 'fit', 0.9],
|
||||
});
|
||||
});
|
||||
|
||||
test('restores the zoom from a view state', async () => {
|
||||
const viewer = createViewer();
|
||||
await viewer.setSource(createImageDataUrl(40, 20), { alt: 'red' });
|
||||
viewer.setScale(3);
|
||||
const viewState = viewer.getViewState();
|
||||
|
||||
await viewer.setSource(createImageDataUrl(40, 20), { alt: 'red' });
|
||||
const scaleAfterNewSource = viewer.scale;
|
||||
await viewer.setSource(createImageDataUrl(40, 20), { alt: 'red', viewState });
|
||||
|
||||
assert.deepStrictEqual({ scaleAfterNewSource, restoredScale: viewer.scale }, { scaleAfterNewSource: 'fit', restoredScale: 3 });
|
||||
});
|
||||
|
||||
test('does not zoom or show a context menu for images that fail to load', async () => {
|
||||
const viewer = createViewer();
|
||||
const loaded = await viewer.setSource('data:image/png;base64,AAAA', { alt: 'broken' });
|
||||
viewer.zoomIn();
|
||||
viewer.element.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
|
||||
|
||||
assert.deepStrictEqual({ loaded, hasImage: viewer.hasImage, scale: viewer.scale, contextMenus: contextMenuDelegates.length }, {
|
||||
loaded: false,
|
||||
hasImage: false,
|
||||
scale: 'fit',
|
||||
contextMenus: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('shows the image context menu with the viewer as argument', async () => {
|
||||
const viewer = createViewer();
|
||||
await viewer.setSource(createImageDataUrl(40, 20), { alt: 'red' });
|
||||
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
|
||||
viewer.element.dispatchEvent(event);
|
||||
|
||||
assert.deepStrictEqual(contextMenuDelegates.map(delegate => ({
|
||||
menuId: delegate.menuId,
|
||||
arg: delegate.menuActionOptions?.arg,
|
||||
shouldForwardArgs: delegate.menuActionOptions?.shouldForwardArgs,
|
||||
})), [{ menuId: ImageViewerContextMenuId, arg: viewer, shouldForwardArgs: true }]);
|
||||
assert.strictEqual(event.defaultPrevented, true);
|
||||
});
|
||||
|
||||
test('shows the image context menu from the keyboard when the focus target has focus', async () => {
|
||||
// Like the carousel, the focus target contains the viewer and other controls
|
||||
const focusTarget = $('div');
|
||||
const button = $('button');
|
||||
focusTarget.appendChild(button);
|
||||
container.appendChild(focusTarget);
|
||||
const viewer = createViewer({ focusTarget });
|
||||
focusTarget.appendChild(viewer.element);
|
||||
await viewer.setSource(createImageDataUrl(40, 20), { alt: 'red' });
|
||||
|
||||
const pressShiftF10 = (target: HTMLElement) => {
|
||||
const init: KeyboardEventInit = { key: 'F10', code: 'F10', keyCode: 121, shiftKey: true, bubbles: true, cancelable: true };
|
||||
const keyDown = new KeyboardEvent('keydown', init);
|
||||
target.dispatchEvent(keyDown);
|
||||
target.dispatchEvent(new KeyboardEvent('keyup', init));
|
||||
const viewerRect = viewer.element.getBoundingClientRect();
|
||||
const menus = contextMenuDelegates.map(delegate => {
|
||||
const anchor = delegate.getAnchor() as IAnchor;
|
||||
const anchoredInViewer = anchor.x >= viewerRect.left && anchor.x <= viewerRect.right && anchor.y >= viewerRect.top && anchor.y <= viewerRect.bottom;
|
||||
return { arg: delegate.menuActionOptions?.arg, anchoredInViewer };
|
||||
});
|
||||
contextMenuDelegates.length = 0;
|
||||
return { menus, defaultPrevented: keyDown.defaultPrevented };
|
||||
};
|
||||
|
||||
assert.deepStrictEqual({
|
||||
focusTarget: pressShiftF10(focusTarget),
|
||||
otherControl: pressShiftF10(button),
|
||||
}, {
|
||||
focusTarget: { menus: [{ arg: viewer, anchoredInViewer: true }], defaultPrevented: true },
|
||||
otherControl: { menus: [], defaultPrevented: false },
|
||||
});
|
||||
});
|
||||
|
||||
test('copies the image as a PNG', async () => {
|
||||
const viewer = createViewer();
|
||||
await viewer.setSource(createImageDataUrl(40, 20), { alt: 'red' });
|
||||
|
||||
// The clipboard write must start before `copyImage` yields, since some browsers only
|
||||
// allow clipboard writes while the triggering user gesture is being handled
|
||||
const copy = viewer.copyImage();
|
||||
clipboardService.events.push('returned');
|
||||
await copy;
|
||||
|
||||
assert.deepStrictEqual({ events: clipboardService.events, signature: getSignature(clipboardService.writtenImage) }, { events: ['writeImage', 'returned'], signature: pngSignature });
|
||||
});
|
||||
|
||||
test('copies PNG sources as is and encodes other images in a worker', async () => {
|
||||
const viewer = createViewer();
|
||||
const copy = async (src: string, mimeType: string, read: () => Promise<Uint8Array>) => {
|
||||
await viewer.setSource(src, { alt: 'image', data: { mimeType, read } });
|
||||
const workersBefore = webWorkerService.created;
|
||||
await viewer.copyImage();
|
||||
return { workers: webWorkerService.created - workersBefore, signature: getSignature(clipboardService.writtenImage), written: clipboardService.writtenImage };
|
||||
};
|
||||
|
||||
const pngUrl = createImageDataUrl(40, 20);
|
||||
const png = toBytes(pngUrl);
|
||||
const jpegUrl = createImageDataUrl(40, 20, 'image/jpeg');
|
||||
const jpeg = toBytes(jpegUrl);
|
||||
|
||||
const pngCopy = await copy(pngUrl, 'image/png', async () => png);
|
||||
const jpegCopy = await copy(jpegUrl, 'image/jpeg', async () => jpeg);
|
||||
const mislabeledCopy = await copy(jpegUrl, 'image/png', async () => jpeg);
|
||||
webWorkerService.fail = true;
|
||||
const fallbackCopy = await copy(jpegUrl, 'image/jpeg', async () => jpeg);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
png: { workers: pngCopy.workers, sameBytes: pngCopy.written === png },
|
||||
jpeg: { workers: jpegCopy.workers, signature: jpegCopy.signature },
|
||||
mislabeled: { workers: mislabeledCopy.workers, signature: mislabeledCopy.signature },
|
||||
fallback: { workers: fallbackCopy.workers, signature: fallbackCopy.signature },
|
||||
}, {
|
||||
png: { workers: 0, sameBytes: true },
|
||||
jpeg: { workers: 1, signature: pngSignature },
|
||||
mislabeled: { workers: 1, signature: pngSignature },
|
||||
fallback: { workers: 1, signature: pngSignature },
|
||||
});
|
||||
});
|
||||
|
||||
test('copies the image on the native copy event when focused', async () => {
|
||||
const copyOnNativeEvent = async (viewer: ImageViewer, focusTarget: HTMLElement) => {
|
||||
await viewer.setSource(createImageDataUrl(40, 20), { alt: 'red' });
|
||||
focusTarget.tabIndex = 0;
|
||||
focusTarget.focus();
|
||||
const event = new ClipboardEvent('copy', { bubbles: true, cancelable: true });
|
||||
viewer.element.dispatchEvent(event);
|
||||
const copied = clipboardService.events.length > 0;
|
||||
clipboardService.events.length = 0;
|
||||
return { copied, defaultPrevented: event.defaultPrevented };
|
||||
};
|
||||
|
||||
const viewer = createViewer();
|
||||
const preview = await copyOnNativeEvent(viewer, viewer.element);
|
||||
|
||||
const unfocused = createViewer();
|
||||
await unfocused.setSource(createImageDataUrl(40, 20), { alt: 'red' });
|
||||
const unfocusedEvent = new ClipboardEvent('copy', { bubbles: true, cancelable: true });
|
||||
unfocused.element.dispatchEvent(unfocusedEvent);
|
||||
|
||||
const parent = $('div');
|
||||
container.appendChild(parent);
|
||||
const carouselViewer = createViewer({ focusTarget: parent });
|
||||
parent.appendChild(carouselViewer.element);
|
||||
const carousel = await copyOnNativeEvent(carouselViewer, parent);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
preview,
|
||||
unfocused: { copied: clipboardService.events.length > 0, defaultPrevented: unfocusedEvent.defaultPrevented },
|
||||
carousel,
|
||||
}, {
|
||||
preview: { copied: true, defaultPrevented: true },
|
||||
unfocused: { copied: false, defaultPrevented: false },
|
||||
carousel: { copied: true, defaultPrevented: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('a cancelled pending source does not replace the current image', async () => {
|
||||
const viewer = createViewer();
|
||||
await viewer.setSource(createImageDataUrl(40, 20), { alt: 'current' });
|
||||
const pending = viewer.setSource(createImageDataUrl(10, 10), { alt: 'stale' });
|
||||
viewer.cancelPendingSource();
|
||||
|
||||
assert.deepStrictEqual({ loaded: await pending, naturalSize: viewer.naturalSize }, {
|
||||
loaded: false,
|
||||
naturalSize: { width: 40, height: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
test('a newer source supersedes a pending one', async () => {
|
||||
const viewer = createViewer();
|
||||
const first = viewer.setSource(createImageDataUrl(40, 20), { alt: 'first' });
|
||||
const second = viewer.setSource(createImageDataUrl(10, 10), { alt: 'second' });
|
||||
|
||||
assert.deepStrictEqual({ first: await first, second: await second, naturalSize: viewer.naturalSize }, {
|
||||
first: false,
|
||||
second: true,
|
||||
naturalSize: { width: 10, height: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { timeout } from '../../../../../base/common/async.js';
|
||||
import { VSBuffer } from '../../../../../base/common/buffer.js';
|
||||
import { Event } from '../../../../../base/common/event.js';
|
||||
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
|
||||
import { Schemas } from '../../../../../base/common/network.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { mock } from '../../../../../base/test/common/mock.js';
|
||||
import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { IResourceEditorInput } from '../../../../../platform/editor/common/editor.js';
|
||||
import { EditorInputCapabilities } from '../../../../common/editor.js';
|
||||
import { TestFileDialogService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';
|
||||
import { TestFileService } from '../../../../test/common/workbenchTestServices.js';
|
||||
import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
|
||||
import { FileChangesEvent, FileChangeType, IFileContent, IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js';
|
||||
import { EditorInput } from '../../../../common/editor/editorInput.js';
|
||||
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { IOverlayWebview } from '../../../webview/browser/webview.js';
|
||||
import { AUDIO_PREVIEW_EDITOR_ID, IMAGE_PREVIEW_EDITOR_ID, VIDEO_PREVIEW_EDITOR_ID } from '../../common/mediaPreview.js';
|
||||
import { ImagePreviewEditorInput } from '../../browser/imagePreviewEditorInput.js';
|
||||
import { MediaPlayerEditorInput, MediaPlayerKind } from '../../browser/mediaPlayerEditorInput.js';
|
||||
|
||||
suite('Media Preview - ImagePreviewEditorInput', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('is a read-only resource editor for the image preview', () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
const resource = URI.file('/images/logo.png');
|
||||
|
||||
const input = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, resource, undefined));
|
||||
const sameResource = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, URI.file('/images/logo.png'), undefined));
|
||||
const otherResource = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, URI.file('/images/other.png'), undefined));
|
||||
|
||||
const untyped = input.toUntyped();
|
||||
assert.deepStrictEqual({
|
||||
typeId: input.typeId,
|
||||
editorId: input.editorId,
|
||||
name: input.getName(),
|
||||
readonly: input.hasCapability(EditorInputCapabilities.Readonly),
|
||||
canDropIntoEditor: input.hasCapability(EditorInputCapabilities.CanDropIntoEditor),
|
||||
matchesSameResource: input.matches(sameResource),
|
||||
matchesOtherResource: input.matches(otherResource),
|
||||
matchesUntypedPreview: input.matches({ resource, options: { override: IMAGE_PREVIEW_EDITOR_ID } }),
|
||||
matchesUntypedTextEditor: input.matches({ resource, options: { override: 'default' } }),
|
||||
untyped: { resource: untyped.resource?.toString(), override: untyped.options?.override },
|
||||
}, {
|
||||
typeId: ImagePreviewEditorInput.ID,
|
||||
editorId: IMAGE_PREVIEW_EDITOR_ID,
|
||||
name: 'logo.png',
|
||||
readonly: true,
|
||||
canDropIntoEditor: false,
|
||||
matchesSameResource: true,
|
||||
matchesOtherResource: false,
|
||||
matchesUntypedPreview: true,
|
||||
matchesUntypedTextEditor: false,
|
||||
untyped: { resource: resource.toString(), override: IMAGE_PREVIEW_EDITOR_ID },
|
||||
});
|
||||
});
|
||||
|
||||
test('follows renamed files', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
const input = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, URI.file('/images/logo.png'), undefined));
|
||||
const target = URI.file('/images/renamed/logo.png');
|
||||
|
||||
const result = await input.rename(1, target);
|
||||
const editor = result?.editor as IResourceEditorInput | undefined;
|
||||
assert.deepStrictEqual({ resource: editor?.resource.toString(), override: editor?.options?.override }, { resource: target.toString(), override: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
suite('Media Preview - MediaPlayerEditorInput', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('identifies audio and video previews by kind and resource', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
const createInput = (kind: MediaPlayerKind, resource: URI) => disposables.add(instantiationService.createInstance(MediaPlayerEditorInput, kind, resource, new class extends mock<IOverlayWebview>() {
|
||||
override readonly onMessage = Event.None;
|
||||
override dispose() { }
|
||||
}));
|
||||
|
||||
const audioResource = URI.file('/media/song.mp3');
|
||||
const audio = createInput('audio', audioResource);
|
||||
const video = createInput('video', URI.file('/media/clip.webm'));
|
||||
|
||||
const untyped = audio.toUntyped();
|
||||
assert.deepStrictEqual({
|
||||
typeIds: [audio.typeId, video.typeId],
|
||||
editorIds: [audio.editorId, video.editorId],
|
||||
name: audio.getName(),
|
||||
readonly: audio.hasCapability(EditorInputCapabilities.Readonly),
|
||||
singleton: audio.hasCapability(EditorInputCapabilities.Singleton),
|
||||
matchesSameKindAndResource: audio.matches(createInput('audio', URI.file('/media/song.mp3'))),
|
||||
matchesOtherKind: audio.matches(createInput('video', URI.file('/media/song.mp3'))),
|
||||
matchesUntypedPreview: audio.matches({ resource: audioResource, options: { override: AUDIO_PREVIEW_EDITOR_ID } }),
|
||||
matchesUntypedTextEditor: audio.matches({ resource: audioResource, options: { override: 'default' } }),
|
||||
untyped: { resource: untyped.resource?.toString(), override: untyped.options?.override },
|
||||
}, {
|
||||
typeIds: [MediaPlayerEditorInput.typeId, MediaPlayerEditorInput.typeId],
|
||||
editorIds: [AUDIO_PREVIEW_EDITOR_ID, VIDEO_PREVIEW_EDITOR_ID],
|
||||
name: 'song.mp3',
|
||||
readonly: true,
|
||||
singleton: false,
|
||||
matchesSameKindAndResource: true,
|
||||
matchesOtherKind: false,
|
||||
matchesUntypedPreview: true,
|
||||
matchesUntypedTextEditor: false,
|
||||
untyped: { resource: audioResource.toString(), override: AUDIO_PREVIEW_EDITOR_ID },
|
||||
});
|
||||
|
||||
const target = URI.file('/media/renamed/song.mp3');
|
||||
const renamed = (await audio.rename(1, target))?.editor as IResourceEditorInput | undefined;
|
||||
assert.deepStrictEqual({ resource: renamed?.resource.toString(), override: renamed?.options?.override }, { resource: target.toString(), override: undefined });
|
||||
});
|
||||
|
||||
test('renders again on changes that are not reported to its watcher', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
let renders = 0;
|
||||
const input = disposables.add(instantiationService.createInstance(MediaPlayerEditorInput, 'video', URI.from({ scheme: 'extension-fs', path: '/media/clip.webm' }), new class extends mock<IOverlayWebview>() {
|
||||
override readonly onMessage = Event.None;
|
||||
override setHtml() { renders++; }
|
||||
override dispose() { }
|
||||
}));
|
||||
await input.resolve();
|
||||
|
||||
// Extension file systems report changes to `IFileService.onDidFilesChange` only
|
||||
const fileService = instantiationService.get(IFileService) as TestFileService;
|
||||
// Both changes are rendered together once the render delay has passed
|
||||
fileService.fireFileChanges(new FileChangesEvent([{ resource: input.resource, type: FileChangeType.UPDATED }], false));
|
||||
fileService.fireFileChanges(new FileChangesEvent([{ resource: input.resource, type: FileChangeType.UPDATED }], false));
|
||||
await timeout(1000);
|
||||
|
||||
assert.strictEqual(renders, 2);
|
||||
}));
|
||||
|
||||
test('shows Git LFS pointers as a message within the webview', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
const pointer = VSBuffer.fromString('version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345\n');
|
||||
const fileService = instantiationService.get(IFileService);
|
||||
fileService.stat = async resource => ({ resource, size: pointer.byteLength } as IFileStatWithMetadata);
|
||||
fileService.readFile = async resource => ({ resource, value: pointer, size: pointer.byteLength } as IFileContent);
|
||||
|
||||
let html = '';
|
||||
const input = disposables.add(instantiationService.createInstance(MediaPlayerEditorInput, 'video', URI.from({ scheme: Schemas.git, path: '/media/clip.webm' }), new class extends mock<IOverlayWebview>() {
|
||||
override readonly onMessage = Event.None;
|
||||
override setHtml(value: string) { html = value; }
|
||||
override dispose() { }
|
||||
}));
|
||||
await input.resolve();
|
||||
|
||||
assert.deepStrictEqual({
|
||||
showsError: html.includes('<body class="error"'),
|
||||
showsLfsMessage: html.includes('The video file is stored with Git LFS and is not available for preview.'),
|
||||
hasVideo: html.includes('<video'),
|
||||
}, { showsError: true, showsLfsMessage: true, hasVideo: false });
|
||||
});
|
||||
});
|
||||
|
||||
suite('Media Preview - Save As', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
async function saveAs(instantiationService: TestInstantiationService, input: EditorInput, target: URI | undefined) {
|
||||
(instantiationService.get(IFileDialogService) as TestFileDialogService).setPickFileToSave(target!);
|
||||
const copies: string[][] = [];
|
||||
instantiationService.get(IFileService).copy = async (source: URI, destination: URI, overwrite?: boolean) => {
|
||||
copies.push([source.toString(), destination.toString(), String(overwrite)]);
|
||||
return undefined!;
|
||||
};
|
||||
const result = await input.saveAs(1);
|
||||
return { copies, result: (result as IResourceEditorInput | undefined)?.resource.toString() };
|
||||
}
|
||||
|
||||
test('copies the file to the picked destination', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
const image = URI.file('/media/logo.png');
|
||||
const audio = URI.file('/media/song.mp3');
|
||||
const imageTarget = URI.file('/backup/logo.png');
|
||||
const audioTarget = URI.file('/backup/song.mp3');
|
||||
|
||||
const imageInput = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, image, undefined));
|
||||
const audioInput = disposables.add(instantiationService.createInstance(MediaPlayerEditorInput, 'audio', audio, new class extends mock<IOverlayWebview>() {
|
||||
override readonly onMessage = Event.None;
|
||||
override dispose() { }
|
||||
}));
|
||||
|
||||
assert.deepStrictEqual({
|
||||
image: await saveAs(instantiationService, imageInput, imageTarget),
|
||||
audio: await saveAs(instantiationService, audioInput, audioTarget),
|
||||
cancelled: await saveAs(instantiationService, imageInput, undefined),
|
||||
}, {
|
||||
image: { copies: [[image.toString(), imageTarget.toString(), 'true']], result: imageTarget.toString() },
|
||||
audio: { copies: [[audio.toString(), audioTarget.toString(), 'true']], result: audioTarget.toString() },
|
||||
cancelled: { copies: [], result: undefined },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { mock } from '../../../../../base/test/common/mock.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { GroupIdentifier, IEditorIdentifier, isResourceEditorInput, IUntypedEditorInput } from '../../../../common/editor.js';
|
||||
import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js';
|
||||
import { IEditorService, IUntypedEditorReplacement } from '../../../../services/editor/common/editorService.js';
|
||||
import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';
|
||||
import { ImagePreviewEditorInput } from '../../browser/imagePreviewEditorInput.js';
|
||||
import { reopenInTextEditor } from '../../browser/mediaPreviewErrors.js';
|
||||
|
||||
function describe(input: IUntypedEditorInput): object {
|
||||
return isResourceEditorInput(input) ? { resource: input.resource.toString(), override: input.options?.override } : {};
|
||||
}
|
||||
|
||||
suite('Media Preview - reopenInTextEditor', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('replaces standalone previews and opens previews shown in diffs separately', async () => {
|
||||
const disposables = store.add(new DisposableStore());
|
||||
const instantiationService = workbenchInstantiationService(undefined, disposables);
|
||||
const original = URI.file('/images/before.png');
|
||||
const modified = URI.file('/images/after.png');
|
||||
const failing = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, modified, undefined));
|
||||
const other = disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, URI.file('/images/other.png'), undefined));
|
||||
const diff = disposables.add(instantiationService.createInstance(DiffEditorInput, 'diff', undefined, disposables.add(instantiationService.createInstance(ImagePreviewEditorInput, original, undefined)), failing, true));
|
||||
|
||||
const calls: { kind: string; groupId: GroupIdentifier; editor: object }[] = [];
|
||||
const editorService = new class extends mock<IEditorService>() {
|
||||
override getEditors(): readonly IEditorIdentifier[] {
|
||||
return [{ editor: failing, groupId: 1 }, { editor: other, groupId: 1 }, { editor: diff, groupId: 2 }];
|
||||
}
|
||||
override async replaceEditors(replacements: IUntypedEditorReplacement[], groupId: GroupIdentifier): Promise<void> {
|
||||
for (const { replacement } of replacements) {
|
||||
calls.push({ kind: 'replace', groupId, editor: describe(replacement) });
|
||||
}
|
||||
}
|
||||
override async openEditor(editor: unknown, groupId?: unknown): Promise<undefined> {
|
||||
calls.push({ kind: 'open', groupId: groupId as GroupIdentifier, editor: describe(editor as IUntypedEditorInput) });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
await reopenInTextEditor(failing, editorService);
|
||||
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ kind: 'replace', groupId: 1, editor: { resource: modified.toString(), override: 'default' } },
|
||||
{ kind: 'open', groupId: 2, editor: { resource: modified.toString(), override: 'default' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { VSBuffer } from '../../../../../base/common/buffer.js';
|
||||
import { Schemas } from '../../../../../base/common/network.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { FileService } from '../../../../../platform/files/common/fileService.js';
|
||||
import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js';
|
||||
import { NullLogService } from '../../../../../platform/log/common/log.js';
|
||||
import { isGitLfsPointer } from '../../common/mediaPreview.js';
|
||||
|
||||
suite('Media Preview - isGitLfsPointer', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('detects Git LFS pointers only for small git resources', async () => {
|
||||
const fileService = store.add(new FileService(new NullLogService()));
|
||||
store.add(fileService.registerProvider(Schemas.git, store.add(new InMemoryFileSystemProvider())));
|
||||
store.add(fileService.registerProvider(Schemas.file, store.add(new InMemoryFileSystemProvider())));
|
||||
|
||||
const pointer = 'version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345\n';
|
||||
const write = async (resource: URI, content: string) => {
|
||||
await fileService.writeFile(resource, VSBuffer.fromString(content));
|
||||
return resource;
|
||||
};
|
||||
|
||||
const gitPointer = await write(URI.from({ scheme: Schemas.git, path: '/pointer.png' }), pointer);
|
||||
const gitImage = await write(URI.from({ scheme: Schemas.git, path: '/image.png' }), 'not a pointer');
|
||||
const gitLarge = await write(URI.from({ scheme: Schemas.git, path: '/large.png' }), pointer + 'x'.repeat(2048));
|
||||
const filePointer = await write(URI.from({ scheme: Schemas.file, path: '/pointer.png' }), pointer);
|
||||
const missing = URI.from({ scheme: Schemas.git, path: '/missing.png' });
|
||||
|
||||
assert.deepStrictEqual(await Promise.all([gitPointer, gitImage, gitLarge, filePointer, missing].map(resource => isGitLfsPointer(fileService, resource))), [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,10 @@ export class NativeClipboardService implements IClipboardService {
|
||||
return this.nativeHostService.readImage();
|
||||
}
|
||||
|
||||
async writeImage(data: Uint8Array | Promise<Uint8Array>): Promise<void> {
|
||||
return this.nativeHostService.writeImage(VSBuffer.wrap(await data));
|
||||
}
|
||||
|
||||
async writeText(text: string, type?: 'selection' | 'clipboard'): Promise<void> {
|
||||
this.logService.trace('NativeClipboardService#writeText called with type:', type, ' with text.length:', text.length);
|
||||
return this.nativeHostService.writeClipboardText(text, type);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Schemas } from '../../../../base/common/network.js';
|
||||
import { IWebviewService } from '../../../contrib/webview/browser/webview.js';
|
||||
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { ISharedWebContentExtractorService, NullSharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js';
|
||||
import { IWebWorkerService } from '../../../../platform/webWorker/browser/webWorkerService.js';
|
||||
|
||||
function createSolidPng(r: number, g: number, b: number, width: number = 64, height: number = 64): VSBuffer {
|
||||
const canvas = mainWindow.document.createElement('canvas');
|
||||
@@ -70,6 +71,7 @@ async function renderCarousel(context: ComponentFixtureContext, collection: IIma
|
||||
defineInstance(IWebviewService, new class extends mock<IWebviewService>() { }());
|
||||
defineInstance(IFileDialogService, new class extends mock<IFileDialogService>() { }());
|
||||
defineInstance(ISharedWebContentExtractorService, new NullSharedWebContentExtractorService());
|
||||
defineInstance(IWebWorkerService, new class extends mock<IWebWorkerService>() { }());
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@ export class TestNativeHostService implements INativeHostService {
|
||||
async writeClipboardBuffer(format: string, buffer: VSBuffer, type?: 'selection' | 'clipboard' | undefined): Promise<void> { }
|
||||
async triggerPaste(options?: INativeHostOptions): Promise<void> { }
|
||||
async readImage(): Promise<Uint8Array> { return Uint8Array.from([]); }
|
||||
async writeImage(data: VSBuffer): Promise<void> { }
|
||||
async readClipboardBuffer(format: string): Promise<VSBuffer> { return VSBuffer.wrap(Uint8Array.from([])); }
|
||||
async hasClipboard(format: string, type?: 'selection' | 'clipboard' | undefined): Promise<boolean> { return false; }
|
||||
async windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise<string | undefined> { return undefined; }
|
||||
|
||||
@@ -321,6 +321,9 @@ import './contrib/webviewPanel/browser/webviewPanel.contribution.js';
|
||||
import './contrib/webviewView/browser/webviewView.contribution.js';
|
||||
import './contrib/customEditor/browser/customEditor.contribution.js';
|
||||
|
||||
// Media Preview
|
||||
import './contrib/mediaPreview/browser/mediaPreview.contribution.js';
|
||||
|
||||
// External Uri Opener
|
||||
import './contrib/externalUriOpener/common/externalUriOpener.contribution.js';
|
||||
|
||||
|
||||
Reference in new issue
Block a user