@jupyterlab/git
Version:
A JupyterLab extension for version control using git
245 lines (244 loc) • 13 kB
JavaScript
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>`;
}
}