UNPKG

@jupyterlab/git

Version:

A JupyterLab extension for version control using git

245 lines (244 loc) 13 kB
import { ReactWidget } from '@jupyterlab/apputils'; import { nullTranslator } from '@jupyterlab/translation'; import { PromiseDelegate } from '@lumino/coreutils'; import { Panel } from '@lumino/widgets'; import Slider from '@mui/material/Slider'; import { styled } from '@mui/material/styles'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; import { filesize } from 'filesize'; import * as React from 'react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { challengerImageClass, challengerLabelClass, emptyChallImage, emptyRefImage, imageCol, imageDiffWidgetClass, labelsClass, onionSkinChallengerImage, onionSkinContainer, onionSkinImage, onionSkinImageContainer, onionSkinReferenceImage, referenceImageClass, referenceLabelClass, slider, sliderChallengerCircle, sliderReferenceCircle, swipeBackground, swipeChallengerImage, swipeContainer, swipeImage, swipeReferenceImage, tabClass, tabIndicatorClass, tabsClass, twoUpView } from '../../style/ImageDiffStyle'; export const createImageDiff = async ({ model, toolbar, translator }) => { const widget = new ImageDiffWidget(model, (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab_git')); await widget.ready; return widget; }; const MODES = ['2-up', 'Swipe', 'Onion Skin']; const IMAGE_FILE_TYPES = ['png', 'jpeg', 'jpg']; const whichViewMode = (mode) => { const elements = { '2-up': TwoUp, Swipe, 'Onion Skin': OnionSkin }; return elements[mode]; }; const base64FileSize = (base64str) => { const n = 0.75 * base64str.length; return filesize(n); }; const getFileType = (filename) => { return (filename.substring(filename.lastIndexOf('.') + 1, filename.length) || filename); }; const ImageDiff = ({ reference, referenceLabel, challenger, challengerLabel, mode, trans, fileType }) => { const [modeSelect, setModeSelect] = useState(mode ? mode : '2-up'); const onTabChange = useCallback((event, tab) => { setModeSelect(MODES[tab]); }, [modeSelect]); const ImageDiffView = whichViewMode(modeSelect); return (React.createElement("div", null, React.createElement("div", { className: labelsClass }, React.createElement("span", { className: referenceLabelClass }, referenceLabel), React.createElement("span", { className: challengerLabelClass }, challengerLabel)), React.createElement(Tabs, { classes: { root: tabsClass, indicator: tabIndicatorClass }, value: MODES.indexOf(modeSelect), onChange: onTabChange, variant: "fullWidth" }, React.createElement(Tab, { classes: { root: tabClass }, title: trans.__('View Image Diff in 2-up Mode'), label: trans.__('2-up'), disableFocusRipple: true, disableRipple: true }), React.createElement(Tab, { classes: { root: tabClass }, title: trans.__('View Image Diff in Swipe Mode'), label: trans.__('Swipe'), disableFocusRipple: true, disableRipple: true }), React.createElement(Tab, { classes: { root: tabClass }, title: trans.__('View Image Diff in Onion Skin Mode'), label: trans.__('Onion Skin'), disableFocusRipple: true, disableRipple: true })), React.createElement(ImageDiffView, { reference: reference, challenger: challenger, fileType: fileType }))); }; const TwoUp = ({ reference, challenger, fileType }) => { const [referDimensions, setReferDimensions] = useState([100, 100]); const [challDimensions, setChallDimensions] = useState([100, 100]); const [imageDimensions, setImageDimensions] = useState([100, 100]); const handleReferLoad = (event) => { let width = event.currentTarget.naturalWidth; let height = event.currentTarget.naturalHeight; setReferDimensions([width, height]); width = Math.min(event.currentTarget.width, 400); height = Math.min(event.currentTarget.height, 400); setImageDimensions([width, height]); }; const handleChallLoad = (event) => { let width = event.currentTarget.naturalWidth; let height = event.currentTarget.naturalHeight; setChallDimensions([width, height]); width = Math.min(event.currentTarget.width, 400); height = Math.min(event.currentTarget.height, 400); setImageDimensions([width, height]); }; useEffect(() => { if (!reference) { setReferDimensions([...challDimensions]); } }, [challDimensions]); useEffect(() => { if (!challenger) { setChallDimensions([...referDimensions]); } }, [referDimensions]); return (React.createElement("div", { className: twoUpView }, React.createElement("div", { className: imageCol }, React.createElement("img", { className: `${referenceImageClass} ${!reference ? emptyRefImage : ''}`, src: `data:image/${fileType};base64,${reference}`, alt: 'Reference', onLoad: handleReferLoad, style: !reference ? { width: `${imageDimensions[0]}px`, height: `${imageDimensions[1]}px` } : {} }), React.createElement("label", { htmlFor: referenceImageClass }, `W: ${referDimensions[0]}px | H: ${referDimensions[1]}px | ${base64FileSize(reference)}`)), React.createElement("div", { className: imageCol }, React.createElement("img", { className: `${challengerImageClass} ${!challenger ? emptyChallImage : ''}`, src: `data:image/${fileType};base64,${challenger}`, alt: 'challenger', onLoad: handleChallLoad, style: !challenger ? { width: `${imageDimensions[0]}px`, height: `${imageDimensions[1]}px` } : {} }), React.createElement("label", { htmlFor: challengerImageClass }, `W: ${challDimensions[0]}px | H: ${challDimensions[1]}px | ${base64FileSize(challenger)}`)))); }; const CustomMUISlider = styled(Slider)({ color: 'var(--jp-brand-color1)', '& .MuiSlider-thumb': { backgroundColor: 'var(--jp-brand-color1)' } }); const Slider_ = ({ value, onChange, width, reversed }) => { const circleClasses = reversed ? [sliderChallengerCircle, sliderReferenceCircle] : [sliderReferenceCircle, sliderChallengerCircle]; return (React.createElement("div", { className: slider }, React.createElement("span", { className: circleClasses[0] }, "\u25CF"), React.createElement(CustomMUISlider, { style: { width: `${width + 10}px` }, value: value, onChange: onChange }), React.createElement("span", { className: circleClasses[1] }, "\u25CF"))); }; const Swipe = ({ reference, challenger, fileType }) => { const [sliderValue, setSliderValue] = useState(50); const [sliderWidth, setSliderWidth] = useState(0); const referenceImageRef = useRef(null); const challengerImageRef = useRef(null); const handleSliderChange = (event, newValue) => { if (typeof newValue === 'number') { setSliderValue(newValue); } }; useLayoutEffect(() => { const refWidth = reference ? referenceImageRef.current.offsetWidth : 0; const challWidth = challenger ? challengerImageRef.current.offsetWidth : 0; setSliderWidth(Math.max(refWidth, challWidth) - 10); }, []); return (React.createElement("div", { className: swipeContainer }, React.createElement(Slider_, { value: sliderValue, onChange: handleSliderChange, width: sliderWidth, reversed: true }), React.createElement("div", { className: swipeBackground }, React.createElement("img", { ref: referenceImageRef, src: `data:image/${fileType};base64,${reference}`, className: `${swipeImage} ${swipeReferenceImage} ${!reference ? emptyRefImage : ''}`, style: { clipPath: `polygon(0 0, ${sliderValue - 0.1}% 0, ${sliderValue - 0.1}% 100%, 0% 100%)`, width: sliderWidth ? `${sliderWidth}px` : '' }, alt: "Reference" }), React.createElement("img", { ref: challengerImageRef, src: `data:image/${fileType};base64,${challenger}`, className: `${swipeImage} ${swipeChallengerImage} ${!challenger ? emptyChallImage : ''}`, style: { clipPath: `polygon(${sliderValue + 0.1}% 0, 100% 0, 100% 100%, ${sliderValue + 0.1}% 100%)`, width: sliderWidth ? `${sliderWidth}px` : '' }, alt: "Challenger" })))); }; const OnionSkin = ({ reference, challenger, fileType }) => { const [sliderValue, setSliderValue] = useState(50); const [sliderWidth, setSliderWidth] = useState(40); const referenceImageRef = useRef(null); const challengerImageRef = useRef(null); const handleSliderChange = (event, newValue) => { if (typeof newValue === 'number') { setSliderValue(newValue); } }; useLayoutEffect(() => { const refWidth = reference ? referenceImageRef.current.offsetWidth : 40; const challWidth = challenger ? challengerImageRef.current.offsetWidth : 40; setSliderWidth(Math.max(refWidth, challWidth) - 10); }, []); return (React.createElement("div", { className: onionSkinContainer }, React.createElement(Slider_, { value: sliderValue, onChange: handleSliderChange, width: sliderWidth }), React.createElement("div", { className: onionSkinImageContainer }, React.createElement("img", { src: `data:image/${fileType};base64,${reference}`, alt: "Reference", className: `${onionSkinImage} ${onionSkinReferenceImage} ${!reference ? emptyRefImage : ''}`, style: !reference ? { width: `${sliderWidth}px` } : {}, ref: referenceImageRef }), React.createElement("img", { src: `data:image/${fileType};base64,${challenger}`, alt: "Challenger", className: `${onionSkinImage} ${onionSkinChallengerImage} ${!challenger ? emptyChallImage : ''}`, style: { opacity: sliderValue / 100 }, ref: challengerImageRef })))); }; export class ImageDiffWidget extends Panel { constructor(model, translator) { super(); this.addClass('jp-git-image-diff'); const getReady = new PromiseDelegate(); this._container = this.node; this._isReady = getReady.promise; this._model = model; this._trans = translator !== null && translator !== void 0 ? translator : nullTranslator.load('jupyterlab_git'); this.refresh() .then(() => { getReady.resolve(); }) .catch(reason => { console.error(this._trans.__('Failed to refresh Image diff.'), reason, reason === null || reason === void 0 ? void 0 : reason.traceback); }); } /** * Diff model */ get model() { return this._model; } /** * Promise which fulfills when the widget is ready. */ get ready() { return this._isReady; } get isFileResolved() { // TODO return true; } async getResolvedFile() { // TODO return {}; } async refresh() { try { const referenceLabel = this._model.reference.label; const challengerLabel = this._model.challenger.label; const [reference, challenger] = await Promise.all([ this._model.reference.content(), this._model.challenger.content() ]); const fileType = getFileType(this._model.filename); if (!IMAGE_FILE_TYPES.includes(fileType)) { throw Error(`Image file format ${fileType} not supported. Only ${IMAGE_FILE_TYPES} file extensions are supported' by the image diff view.`); } const reactImageDiffWidget = ReactWidget.create(React.createElement(ImageDiff, { reference: reference, referenceLabel: referenceLabel, challenger: challenger, challengerLabel: challengerLabel, mode: "2-up", trans: this._trans, fileType: fileType })); this.addClass(imageDiffWidgetClass); this.addWidget(reactImageDiffWidget); } catch (reason) { this.showError(reason); } } /** * Display an error instead of the file diff * * @param error Error object */ showError(error) { console.error(this._trans.__('Failed to load file diff.'), error, error === null || error === void 0 ? void 0 : error.traceback); while (this.widgets.length > 0) { this.widgets[0].dispose(); } const msg = (error.message || error).replace('\n', '<br />'); this.node.innerHTML = `<p style="color: unset" class="jp-git-diff-error"> <span>${this._trans.__('Error Loading Image Diff:')}</span> <span class="jp-git-diff-error-message">${msg}</span> </p>`; } }