imagePreview.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. /*---------------------------------------------------------------------------------------------
  2. * Copyright (c) Microsoft Corporation. All rights reserved.
  3. * Licensed under the MIT License. See License.txt in the project root for license information.
  4. *--------------------------------------------------------------------------------------------*/
  5. // @ts-check
  6. "use strict";
  7. (function () {
  8. /**
  9. * @param {number} value
  10. * @param {number} min
  11. * @param {number} max
  12. * @return {number}
  13. */
  14. function clamp(value, min, max) {
  15. return Math.min(Math.max(value, min), max);
  16. }
  17. function getSettings() {
  18. const element = document.getElementById('image-preview-settings');
  19. if (element) {
  20. const data = element.getAttribute('data-settings');
  21. if (data) {
  22. return JSON.parse(data);
  23. }
  24. }
  25. throw new Error(`Could not load settings`);
  26. }
  27. /**
  28. * Enable image-rendering: pixelated for images scaled by more than this.
  29. */
  30. const PIXELATION_THRESHOLD = 3;
  31. const SCALE_PINCH_FACTOR = 0.075;
  32. const MAX_SCALE = 20;
  33. const MIN_SCALE = 0.1;
  34. const zoomLevels = [
  35. 0.1,
  36. 0.2,
  37. 0.3,
  38. 0.4,
  39. 0.5,
  40. 0.6,
  41. 0.7,
  42. 0.8,
  43. 0.9,
  44. 1,
  45. 1.5,
  46. 2,
  47. 3,
  48. 5,
  49. 7,
  50. 10,
  51. 15,
  52. 20
  53. ];
  54. const settings = getSettings();
  55. const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
  56. // @ts-ignore
  57. const vscode = acquireVsCodeApi();
  58. const initialState = vscode.getState() || { scale: 'fit', offsetX: 0, offsetY: 0 };
  59. // State
  60. let scale = initialState.scale;
  61. let ctrlPressed = false;
  62. let altPressed = false;
  63. let hasLoadedImage = false;
  64. let consumeClick = true;
  65. let isActive = false;
  66. // Elements
  67. const container = document.body;
  68. const image = document.createElement('img');
  69. function updateScale(newScale) {
  70. if (!image || !hasLoadedImage || !image.parentElement) {
  71. return;
  72. }
  73. if (newScale === 'fit') {
  74. scale = 'fit';
  75. image.classList.add('scale-to-fit');
  76. image.classList.remove('pixelated');
  77. // @ts-ignore Non-standard CSS property
  78. image.style.zoom = 'normal';
  79. vscode.setState(undefined);
  80. } else {
  81. scale = clamp(newScale, MIN_SCALE, MAX_SCALE);
  82. if (scale >= PIXELATION_THRESHOLD) {
  83. image.classList.add('pixelated');
  84. } else {
  85. image.classList.remove('pixelated');
  86. }
  87. const dx = (window.scrollX + container.clientWidth / 2) / container.scrollWidth;
  88. const dy = (window.scrollY + container.clientHeight / 2) / container.scrollHeight;
  89. image.classList.remove('scale-to-fit');
  90. // @ts-ignore Non-standard CSS property
  91. image.style.zoom = scale;
  92. const newScrollX = container.scrollWidth * dx - container.clientWidth / 2;
  93. const newScrollY = container.scrollHeight * dy - container.clientHeight / 2;
  94. window.scrollTo(newScrollX, newScrollY);
  95. vscode.setState({ scale: scale, offsetX: newScrollX, offsetY: newScrollY });
  96. }
  97. vscode.postMessage({
  98. type: 'zoom',
  99. value: scale
  100. });
  101. }
  102. function setActive(value) {
  103. isActive = value;
  104. if (value) {
  105. if (isMac ? altPressed : ctrlPressed) {
  106. container.classList.remove('zoom-in');
  107. container.classList.add('zoom-out');
  108. } else {
  109. container.classList.remove('zoom-out');
  110. container.classList.add('zoom-in');
  111. }
  112. } else {
  113. ctrlPressed = false;
  114. altPressed = false;
  115. container.classList.remove('zoom-out');
  116. container.classList.remove('zoom-in');
  117. }
  118. }
  119. function firstZoom() {
  120. if (!image || !hasLoadedImage) {
  121. return;
  122. }
  123. scale = image.clientWidth / image.naturalWidth;
  124. updateScale(scale);
  125. }
  126. function zoomIn() {
  127. if (scale === 'fit') {
  128. firstZoom();
  129. }
  130. let i = 0;
  131. for (; i < zoomLevels.length; ++i) {
  132. if (zoomLevels[i] > scale) {
  133. break;
  134. }
  135. }
  136. updateScale(zoomLevels[i] || MAX_SCALE);
  137. }
  138. function zoomOut() {
  139. if (scale === 'fit') {
  140. firstZoom();
  141. }
  142. let i = zoomLevels.length - 1;
  143. for (; i >= 0; --i) {
  144. if (zoomLevels[i] < scale) {
  145. break;
  146. }
  147. }
  148. updateScale(zoomLevels[i] || MIN_SCALE);
  149. }
  150. window.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => {
  151. if (!image || !hasLoadedImage) {
  152. return;
  153. }
  154. ctrlPressed = e.ctrlKey;
  155. altPressed = e.altKey;
  156. if (isMac ? altPressed : ctrlPressed) {
  157. container.classList.remove('zoom-in');
  158. container.classList.add('zoom-out');
  159. }
  160. });
  161. window.addEventListener('keyup', (/** @type {KeyboardEvent} */ e) => {
  162. if (!image || !hasLoadedImage) {
  163. return;
  164. }
  165. ctrlPressed = e.ctrlKey;
  166. altPressed = e.altKey;
  167. if (!(isMac ? altPressed : ctrlPressed)) {
  168. container.classList.remove('zoom-out');
  169. container.classList.add('zoom-in');
  170. }
  171. });
  172. container.addEventListener('mousedown', (/** @type {MouseEvent} */ e) => {
  173. if (!image || !hasLoadedImage) {
  174. return;
  175. }
  176. if (e.button !== 0) {
  177. return;
  178. }
  179. ctrlPressed = e.ctrlKey;
  180. altPressed = e.altKey;
  181. consumeClick = !isActive;
  182. });
  183. container.addEventListener('click', (/** @type {MouseEvent} */ e) => {
  184. if (!image || !hasLoadedImage) {
  185. return;
  186. }
  187. if (e.button !== 0) {
  188. return;
  189. }
  190. if (consumeClick) {
  191. consumeClick = false;
  192. return;
  193. }
  194. // left click
  195. if (scale === 'fit') {
  196. firstZoom();
  197. }
  198. if (!(isMac ? altPressed : ctrlPressed)) { // zoom in
  199. zoomIn();
  200. } else {
  201. zoomOut();
  202. }
  203. });
  204. container.addEventListener('wheel', (/** @type {WheelEvent} */ e) => {
  205. // Prevent pinch to zoom
  206. if (e.ctrlKey) {
  207. e.preventDefault();
  208. }
  209. if (!image || !hasLoadedImage) {
  210. return;
  211. }
  212. const isScrollWheelKeyPressed = isMac ? altPressed : ctrlPressed;
  213. if (!isScrollWheelKeyPressed && !e.ctrlKey) { // pinching is reported as scroll wheel + ctrl
  214. return;
  215. }
  216. if (scale === 'fit') {
  217. firstZoom();
  218. }
  219. const delta = e.deltaY > 0 ? 1 : -1;
  220. updateScale(scale * (1 - delta * SCALE_PINCH_FACTOR));
  221. }, { passive: false });
  222. window.addEventListener('scroll', e => {
  223. if (!image || !hasLoadedImage || !image.parentElement || scale === 'fit') {
  224. return;
  225. }
  226. const entry = vscode.getState();
  227. if (entry) {
  228. vscode.setState({ scale: entry.scale, offsetX: window.scrollX, offsetY: window.scrollY });
  229. }
  230. }, { passive: true });
  231. container.classList.add('image');
  232. image.classList.add('scale-to-fit');
  233. image.addEventListener('load', () => {
  234. if (hasLoadedImage) {
  235. return;
  236. }
  237. hasLoadedImage = true;
  238. vscode.postMessage({
  239. type: 'size',
  240. value: `${image.naturalWidth}x${image.naturalHeight}`,
  241. });
  242. document.body.classList.remove('loading');
  243. document.body.classList.add('ready');
  244. document.body.append(image);
  245. updateScale(scale);
  246. if (initialState.scale !== 'fit') {
  247. window.scrollTo(initialState.offsetX, initialState.offsetY);
  248. }
  249. });
  250. image.addEventListener('error', e => {
  251. if (hasLoadedImage) {
  252. return;
  253. }
  254. hasLoadedImage = true;
  255. document.body.classList.add('error');
  256. document.body.classList.remove('loading');
  257. });
  258. image.src = settings.src;
  259. document.querySelector('.open-file-link')?.addEventListener('click', (e) => {
  260. e.preventDefault();
  261. vscode.postMessage({
  262. type: 'reopen-as-text',
  263. });
  264. });
  265. window.addEventListener('message', e => {
  266. if (e.origin !== window.origin) {
  267. console.error('Dropping message from unknown origin in image preview');
  268. return;
  269. }
  270. switch (e.data.type) {
  271. case 'setScale': {
  272. updateScale(e.data.scale);
  273. break;
  274. }
  275. case 'setActive': {
  276. setActive(e.data.value);
  277. break;
  278. }
  279. case 'zoomIn': {
  280. zoomIn();
  281. break;
  282. }
  283. case 'zoomOut': {
  284. zoomOut();
  285. break;
  286. }
  287. case 'copyImage': {
  288. copyImage();
  289. break;
  290. }
  291. }
  292. });
  293. document.addEventListener('copy', () => {
  294. copyImage();
  295. });
  296. async function copyImage(retries = 5) {
  297. if (!document.hasFocus() && retries > 0) {
  298. // copyImage is called at the same time as webview.reveal, which means this function is running whilst the webview is gaining focus.
  299. // Since navigator.clipboard.write requires the document to be focused, we need to wait for focus.
  300. // 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.
  301. setTimeout(() => { copyImage(retries - 1); }, 20);
  302. return;
  303. }
  304. try {
  305. await navigator.clipboard.write([new ClipboardItem({
  306. 'image/png': new Promise((resolve, reject) => {
  307. const canvas = document.createElement('canvas');
  308. canvas.width = image.naturalWidth;
  309. canvas.height = image.naturalHeight;
  310. canvas.getContext('2d').drawImage(image, 0, 0);
  311. canvas.toBlob((blob) => {
  312. resolve(blob);
  313. canvas.remove();
  314. }, 'image/png');
  315. })
  316. })]);
  317. } catch (e) {
  318. console.error(e);
  319. }
  320. }
  321. }());