Summary: Documents can now be flagged as tutorials, which causes them to display Markdown-formatted slides from a special GristDocTutorial table. Tutorial documents are forked on open, and remember the last slide a user was on. They can be restarted too, which prepares a new fork of the tutorial. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3813pull/472/head
parent
210aa92eed
commit
be8e13df64
@ -0,0 +1,96 @@
|
||||
.doc-tutorial-popup h1,
|
||||
.doc-tutorial-popup h2,
|
||||
.doc-tutorial-popup h3,
|
||||
.doc-tutorial-popup p,
|
||||
.doc-tutorial-popup li {
|
||||
color: var(--grist-theme-text, #262633);
|
||||
}
|
||||
|
||||
.doc-tutorial-popup h1 {
|
||||
margin: 0px 0px 24px 0px;
|
||||
font-weight: 500;
|
||||
font-size: 28px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup h2 {
|
||||
margin: 20px 0px 10px 0px;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup h3 {
|
||||
margin: 20px 0px 10px 0px;
|
||||
font-weight: 400;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup p {
|
||||
margin: 0px 0px 16px 0px;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup a,
|
||||
.doc-tutorial-popup a:hover {
|
||||
color: var(--grist-theme-link, #16B378);
|
||||
}
|
||||
|
||||
.doc-tutorial-popup li {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup ol,
|
||||
.doc-tutorial-popup ul {
|
||||
margin: 0px 0px 10px 0px;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup code {
|
||||
padding: 2px 5px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E1E4E5;
|
||||
color: #333333;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup iframe {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup-thumbnail {
|
||||
position: relative;
|
||||
margin: 20px 0px 30px 0px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup-thumbnail img {
|
||||
width: 100%;
|
||||
border: 1px solid var(--grist-theme-tutorials-popup-border, #D9D9D9);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup-thumbnail-icon-wrapper {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 4px;
|
||||
background-color: #D9D9D9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.doc-tutorial-popup-thumbnail-icon {
|
||||
mask-image: var(--icon-Maximize);
|
||||
-webkit-mask-image: var(--icon-Maximize);
|
||||
background-color: var(--grist-theme-accent-icon, var(--grist-color-light-green));
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
@ -0,0 +1,639 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {isNarrowScreen, isNarrowScreenObs, mediaXSmall, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {confirmModal, modal} from 'app/client/ui2018/modals';
|
||||
import {parseUrlId} from 'app/common/gristUrls';
|
||||
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
|
||||
import {marked} from 'marked';
|
||||
import debounce = require('lodash/debounce');
|
||||
import range = require('lodash/range');
|
||||
import sortBy = require('lodash/sortBy');
|
||||
|
||||
const POPUP_PADDING_PX = 16;
|
||||
|
||||
interface DocTutorialSlide {
|
||||
slideContent: string;
|
||||
boxContent?: string;
|
||||
slideTitle?: string;
|
||||
imageUrls: string[];
|
||||
}
|
||||
|
||||
const testId = makeTestId('test-doc-tutorial-');
|
||||
|
||||
export class DocTutorial extends Disposable {
|
||||
private _appModel = this._gristDoc.docPageModel.appModel;
|
||||
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
|
||||
private _docComm = this._gristDoc.docComm;
|
||||
private _docData = this._gristDoc.docData;
|
||||
private _docId = this._gristDoc.docId();
|
||||
private _popupElement: HTMLElement | null = null;
|
||||
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
||||
private _currentSlideIndex = Observable.create(this,
|
||||
this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0);
|
||||
private _isMinimized = Observable.create(this, false);
|
||||
|
||||
private _clientX: number;
|
||||
private _clientY: number;
|
||||
|
||||
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
|
||||
// Save new position immediately if at least 1 second has passed since the last change.
|
||||
leading: true,
|
||||
// Otherwise, wait for the new position to settle for 1 second before saving it.
|
||||
trailing: true
|
||||
});
|
||||
|
||||
constructor(private _gristDoc: GristDoc) {
|
||||
super();
|
||||
|
||||
this._handleMouseDown = this._handleMouseDown.bind(this);
|
||||
this._handleMouseMove = this._handleMouseMove.bind(this);
|
||||
this._handleMouseUp = this._handleMouseUp.bind(this);
|
||||
this._handleTouchStart = this._handleTouchStart.bind(this);
|
||||
this._handleTouchMove = this._handleTouchMove.bind(this);
|
||||
this._handleTouchEnd = this._handleTouchEnd.bind(this);
|
||||
this._handleWindowResize = this._handleWindowResize.bind(this);
|
||||
|
||||
this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup()));
|
||||
|
||||
this.onDispose(() => {
|
||||
this._closePopup();
|
||||
});
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this._showPopup();
|
||||
await this._loadSlides();
|
||||
}
|
||||
|
||||
private async _loadSlides() {
|
||||
const tableId = 'GristDocTutorial';
|
||||
if (!this._docData.getTable(tableId)) {
|
||||
throw new Error('DocTutorial failed to find table GristDocTutorial');
|
||||
}
|
||||
|
||||
await this._docComm.waitForInitialization();
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
await this._docData.fetchTable(tableId);
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
const tableData = this._docData.getTable(tableId)!;
|
||||
const slides = (await Promise.all(
|
||||
sortBy(tableData.getRowIds(), tableData.getRowPropFunc('manualSort') as any)
|
||||
.map(async rowId => {
|
||||
let slideTitle: string | undefined;
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
const getValue = (colId: string): string | undefined => {
|
||||
const value = tableData.getValue(rowId, colId);
|
||||
return value ? String(value) : undefined;
|
||||
};
|
||||
|
||||
const walkTokens = (token: marked.Token) => {
|
||||
if (token.type === 'image') {
|
||||
imageUrls.push(token.href);
|
||||
}
|
||||
|
||||
if (!slideTitle && token.type === 'heading' && token.depth === 1) {
|
||||
slideTitle = token.text;
|
||||
}
|
||||
};
|
||||
|
||||
let slideContent = getValue('slide_content');
|
||||
if (!slideContent) { return null; }
|
||||
slideContent = sanitizeHTML(await marked.parse(slideContent, {
|
||||
async: true, renderer, walkTokens
|
||||
}));
|
||||
|
||||
let boxContent = getValue('box_content');
|
||||
if (boxContent) {
|
||||
boxContent = sanitizeHTML(await marked.parse(boxContent, {
|
||||
async: true, renderer, walkTokens
|
||||
}));
|
||||
}
|
||||
return {
|
||||
slideContent,
|
||||
boxContent,
|
||||
slideTitle,
|
||||
imageUrls,
|
||||
};
|
||||
})
|
||||
)).filter(slide => slide !== null) as DocTutorialSlide[];
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
if (slides.length === 0) {
|
||||
throw new Error('DocTutorial failed to find slides in table GristDocTutorial');
|
||||
}
|
||||
|
||||
this._slides.set(slides);
|
||||
}
|
||||
|
||||
private _showPopup() {
|
||||
this._popupElement = this._buildPopup();
|
||||
document.body.appendChild(this._popupElement);
|
||||
|
||||
const topPaddingPx = getTopPopupPaddingPx();
|
||||
const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_PADDING_PX;
|
||||
const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx;
|
||||
this._popupElement.style.left = `${initialLeft}px`;
|
||||
this._popupElement.style.top = `${initialTop}px`;
|
||||
}
|
||||
|
||||
private _closePopup() {
|
||||
if (!this._popupElement) { return; }
|
||||
|
||||
document.body.removeChild(this._popupElement);
|
||||
dom.domDispose(this._popupElement);
|
||||
this._popupElement = null;
|
||||
}
|
||||
|
||||
private _handleMouseDown(ev: MouseEvent) {
|
||||
this._clientX = ev.clientX;
|
||||
this._clientY = ev.clientY;
|
||||
document.addEventListener('mousemove', this._handleMouseMove);
|
||||
document.addEventListener('mouseup', this._handleMouseUp);
|
||||
}
|
||||
|
||||
private _handleTouchStart(ev: TouchEvent) {
|
||||
this._clientX = ev.touches[0].clientX;
|
||||
this._clientY = ev.touches[0].clientY;
|
||||
document.addEventListener('touchmove', this._handleTouchMove);
|
||||
document.addEventListener('touchend', this._handleTouchEnd);
|
||||
}
|
||||
|
||||
private _handleMouseMove({clientX, clientY}: MouseEvent) {
|
||||
this._handleMove(clientX, clientY);
|
||||
}
|
||||
|
||||
private _handleTouchMove({touches}: TouchEvent) {
|
||||
this._handleMove(touches[0].clientX, touches[0].clientY);
|
||||
}
|
||||
|
||||
private _handleMove(clientX: number, clientY: number) {
|
||||
const deltaX = clientX - this._clientX;
|
||||
const deltaY = clientY - this._clientY;
|
||||
let newLeft = this._popupElement!.offsetLeft + deltaX;
|
||||
let newTop = this._popupElement!.offsetTop + deltaY;
|
||||
|
||||
const topPaddingPx = getTopPopupPaddingPx();
|
||||
if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; }
|
||||
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
||||
if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
||||
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX;
|
||||
}
|
||||
if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) {
|
||||
newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx;
|
||||
}
|
||||
|
||||
this._popupElement!.style.left = `${newLeft}px`;
|
||||
this._popupElement!.style.top = `${newTop}px`;
|
||||
this._clientX = clientX;
|
||||
this._clientY = clientY;
|
||||
}
|
||||
|
||||
private _handleMouseUp() {
|
||||
document.removeEventListener('mousemove', this._handleMouseMove);
|
||||
document.removeEventListener('mouseup', this._handleMouseUp);
|
||||
document.body.removeEventListener('mouseleave', this._handleMouseUp);
|
||||
}
|
||||
|
||||
private _handleTouchEnd() {
|
||||
document.removeEventListener('touchmove', this._handleTouchMove);
|
||||
document.removeEventListener('touchend', this._handleTouchEnd);
|
||||
document.body.removeEventListener('touchcancel', this._handleTouchEnd);
|
||||
}
|
||||
|
||||
private _handleWindowResize() {
|
||||
this._repositionPopup();
|
||||
}
|
||||
|
||||
private _repositionPopup() {
|
||||
let newLeft = this._popupElement!.offsetLeft;
|
||||
let newTop = this._popupElement!.offsetTop;
|
||||
|
||||
const topPaddingPx = getTopPopupPaddingPx();
|
||||
if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; }
|
||||
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
||||
if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
||||
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX;
|
||||
}
|
||||
if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) {
|
||||
newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx;
|
||||
}
|
||||
|
||||
this._popupElement!.style.left = `${newLeft}px`;
|
||||
this._popupElement!.style.top = `${newTop}px`;
|
||||
}
|
||||
|
||||
private async _saveCurrentSlidePosition() {
|
||||
const currentOptions = this._currentDoc?.options ?? {};
|
||||
await this._appModel.api.updateDoc(this._docId, {
|
||||
options: {
|
||||
...currentOptions,
|
||||
tutorial: {
|
||||
lastSlideIndex: this._currentSlideIndex.get(),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _changeSlide(slideIndex: number) {
|
||||
this._currentSlideIndex.set(slideIndex);
|
||||
await this._saveCurrentSlidePositionDebounced();
|
||||
}
|
||||
|
||||
private async _previousSlide() {
|
||||
await this._changeSlide(this._currentSlideIndex.get() - 1);
|
||||
}
|
||||
|
||||
private async _nextSlide() {
|
||||
await this._changeSlide(this._currentSlideIndex.get() + 1);
|
||||
}
|
||||
|
||||
private async _finishTutorial() {
|
||||
this._saveCurrentSlidePositionDebounced.cancel();
|
||||
await this._saveCurrentSlidePosition();
|
||||
await urlState().pushUrl({});
|
||||
}
|
||||
|
||||
private async _restartTutorial() {
|
||||
const doRestart = async () => {
|
||||
const urlId = this._currentDoc!.id;
|
||||
const {trunkId} = parseUrlId(urlId);
|
||||
const docApi = this._appModel.api.getDocAPI(urlId);
|
||||
await docApi.replace({sourceDocId: trunkId, resetTutorialMetadata: true});
|
||||
};
|
||||
|
||||
confirmModal(
|
||||
'Do you want to restart the tutorial? All progress will be lost.',
|
||||
'Restart',
|
||||
doRestart
|
||||
);
|
||||
}
|
||||
|
||||
private _restartGIFs() {
|
||||
return (element: HTMLElement) => {
|
||||
setTimeout(() => {
|
||||
const imgs = element.querySelectorAll('img');
|
||||
for (const img of imgs) {
|
||||
// Re-assigning src to itself is a neat way to restart a GIF.
|
||||
// eslint-disable-next-line no-self-assign
|
||||
img.src = img.src;
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
}
|
||||
|
||||
private _buildPopup() {
|
||||
return cssPopup(
|
||||
{tabIndex: '-1'},
|
||||
cssPopupHeader(
|
||||
dom.domComputed(this._isMinimized, isMinimized => {
|
||||
return [
|
||||
cssPopupHeaderSpacer(),
|
||||
cssPopupTitle(
|
||||
cssPopupTitleText(dom.text(this._gristDoc.docPageModel.currentDocTitle)),
|
||||
testId('popup-title'),
|
||||
),
|
||||
cssPopupHeaderButton(
|
||||
isMinimized ? icon('Maximize'): icon('Minimize'),
|
||||
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
|
||||
dom.on('click', () => {
|
||||
this._isMinimized.set(!this._isMinimized.get());
|
||||
this._repositionPopup();
|
||||
}),
|
||||
testId('popup-minimize-maximize'),
|
||||
),
|
||||
];
|
||||
}),
|
||||
dom.on('mousedown', this._handleMouseDown),
|
||||
dom.on('touchstart', this._handleTouchStart),
|
||||
testId('popup-header'),
|
||||
),
|
||||
dom.maybe(use => !use(this._isMinimized), () => [
|
||||
dom.domComputed(use => {
|
||||
const slides = use(this._slides);
|
||||
const slideIndex = use(this._currentSlideIndex);
|
||||
const slide = slides?.[slideIndex];
|
||||
return cssPopupBody(
|
||||
!slide ? cssSpinner(loadingSpinner()) : [
|
||||
dom('div', elem => {
|
||||
elem.innerHTML = slide.slideContent;
|
||||
}),
|
||||
!slide.boxContent ? null : cssTryItOutBox(
|
||||
dom('div', elem => { elem.innerHTML = slide.boxContent!; }),
|
||||
),
|
||||
dom.on('click', (ev) => {
|
||||
if((ev.target as HTMLElement).tagName !== 'IMG') {
|
||||
return;
|
||||
}
|
||||
|
||||
this._openLightbox((ev.target as HTMLImageElement).src);
|
||||
}),
|
||||
this._restartGIFs(),
|
||||
],
|
||||
testId('popup-body'),
|
||||
);
|
||||
}),
|
||||
cssPopupFooter(
|
||||
dom.domComputed(use => {
|
||||
const slides = use(this._slides);
|
||||
if (!slides) { return null; }
|
||||
|
||||
const slideIndex = use(this._currentSlideIndex);
|
||||
const numSlides = slides.length;
|
||||
const isFirstSlide = slideIndex === 0;
|
||||
const isLastSlide = slideIndex === numSlides - 1;
|
||||
return [
|
||||
cssFooterButtonsLeft(
|
||||
cssPopupFooterButton(icon('Undo'),
|
||||
hoverTooltip('Restart Tutorial', {key: 'docTutorialTooltip'}),
|
||||
dom.on('click', () => this._restartTutorial()),
|
||||
testId('popup-restart'),
|
||||
),
|
||||
),
|
||||
cssProgressBar(
|
||||
range(slides.length).map((i) => cssProgressBarDot(
|
||||
{title: slides[i].slideTitle},
|
||||
cssProgressBarDot.cls('-current', i === slideIndex),
|
||||
i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)),
|
||||
testId(`popup-slide-${i + 1}`),
|
||||
)),
|
||||
),
|
||||
cssFooterButtonsRight(
|
||||
basicButton('Previous',
|
||||
dom.on('click', async () => {
|
||||
await this._previousSlide();
|
||||
}),
|
||||
{style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`},
|
||||
testId('popup-previous'),
|
||||
),
|
||||
primaryButton(isLastSlide ? 'Finish': 'Next',
|
||||
isLastSlide
|
||||
? dom.on('click', async () => await this._finishTutorial())
|
||||
: dom.on('click', async () => await this._nextSlide()),
|
||||
testId('popup-next'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}),
|
||||
testId('popup-footer'),
|
||||
),
|
||||
]),
|
||||
// Pre-fetch images from all slides and store them in a hidden div.
|
||||
dom.maybe(this._slides, slides =>
|
||||
dom('div',
|
||||
{style: 'display: none;'},
|
||||
dom.forEach(slides, slide => {
|
||||
if (slide.imageUrls.length === 0) { return null; }
|
||||
|
||||
return dom('div', slide.imageUrls.map(src => dom('img', {src})));
|
||||
}),
|
||||
),
|
||||
),
|
||||
() => { window.addEventListener('resize', this._handleWindowResize); },
|
||||
dom.onDispose(() => {
|
||||
document.removeEventListener('mousemove', this._handleMouseMove);
|
||||
document.removeEventListener('mouseup', this._handleMouseUp);
|
||||
document.removeEventListener('touchmove', this._handleTouchMove);
|
||||
document.removeEventListener('touchend', this._handleTouchEnd);
|
||||
window.removeEventListener('resize', this._handleWindowResize);
|
||||
}),
|
||||
cssPopup.cls('-minimized', this._isMinimized),
|
||||
cssPopup.cls('-mobile', isNarrowScreenObs()),
|
||||
dom.cls('doc-tutorial-popup'),
|
||||
testId('popup'),
|
||||
);
|
||||
}
|
||||
|
||||
private _openLightbox(src: string) {
|
||||
modal((ctl) => {
|
||||
this.onDispose(ctl.close);
|
||||
return [
|
||||
cssFullScreenModal.cls(''),
|
||||
cssModalCloseButton('CrossBig',
|
||||
dom.on('click', () => ctl.close()),
|
||||
testId('lightbox-close'),
|
||||
),
|
||||
cssModalContent(cssModalImage({src}, testId('lightbox-image'))),
|
||||
dom.on('click', (ev, elem) => void (ev.target === elem ? ctl.close() : null)),
|
||||
testId('lightbox'),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getTopPopupPaddingPx(): number {
|
||||
// On mobile, we need additional padding to avoid blocking the top and bottom bars.
|
||||
return POPUP_PADDING_PX + (isNarrowScreen() ? 50 : 0);
|
||||
}
|
||||
|
||||
const POPUP_HEIGHT = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`;
|
||||
const POPUP_HEIGHT_MOBILE = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px) - (2 * 50px)))`;
|
||||
const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`;
|
||||
|
||||
const cssPopup = styled('div', `
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 2px solid ${theme.accentBorder};
|
||||
border-radius: 5px;
|
||||
z-index: 999;
|
||||
height: ${POPUP_HEIGHT};
|
||||
width: ${POPUP_WIDTH};
|
||||
background-color: ${theme.popupBg};
|
||||
box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};
|
||||
outline: unset;
|
||||
|
||||
&-mobile {
|
||||
height: ${POPUP_HEIGHT_MOBILE};
|
||||
}
|
||||
|
||||
&-minimized {
|
||||
max-width: 225px;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
&-minimized:not(&-mobile) {
|
||||
max-height: ${POPUP_HEIGHT};
|
||||
}
|
||||
|
||||
&-minimized&-mobile {
|
||||
max-height: ${POPUP_HEIGHT_MOBILE};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPopupHeader = styled('div', `
|
||||
display: flex;
|
||||
color: ${theme.tutorialsPopupHeaderFg};
|
||||
--icon-color: ${theme.tutorialsPopupHeaderFg};
|
||||
background-color: ${theme.accentBorder};
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
height: 30px;
|
||||
user-select: none;
|
||||
column-gap: 8px;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPopupTitle = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
`);
|
||||
|
||||
const cssPopupTitleText = styled('div', `
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`);
|
||||
|
||||
const cssPopupBody = styled('div', `
|
||||
flex-grow: 1;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
`);
|
||||
|
||||
const cssPopupFooter = styled('div', `
|
||||
display: flex;
|
||||
column-gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 16px 24px 16px;
|
||||
border-top: 1px solid ${theme.tutorialsPopupBorder};
|
||||
`);
|
||||
|
||||
const cssTryItOutBox = styled('div', `
|
||||
margin-top: 16px;
|
||||
padding: 24px;
|
||||
border-radius: 4px;
|
||||
background-color: ${theme.tutorialsPopupBoxBg};
|
||||
`);
|
||||
|
||||
const cssPopupHeaderButton = styled('div', `
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPopupHeaderSpacer = styled('div', `
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`);
|
||||
|
||||
const cssPopupFooterButton = styled('div', `
|
||||
--icon-color: ${theme.controlSecondaryFg};
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssProgressBar = styled('div', `
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-grow: 1;
|
||||
flex-wrap: wrap;
|
||||
`);
|
||||
|
||||
const cssProgressBarDot = styled('div', `
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
align-self: center;
|
||||
cursor: pointer;
|
||||
background-color: ${theme.progressBarBg};
|
||||
|
||||
&-current {
|
||||
cursor: default;
|
||||
background-color: ${theme.progressBarFg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFooterButtonsLeft = styled('div', `
|
||||
flex-shrink: 0;
|
||||
`);
|
||||
|
||||
const cssFooterButtonsRight = styled('div', `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
column-gap: 8px;
|
||||
flex-shrink: 0;
|
||||
min-width: 140px;
|
||||
|
||||
@media ${mediaXSmall} {
|
||||
& {
|
||||
flex-direction: column;
|
||||
row-gap: 8px;
|
||||
column-gap: 0px;
|
||||
min-width: 0px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFullScreenModal = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 8px;
|
||||
background-color: initial;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
box-shadow: none;
|
||||
padding: 0px;
|
||||
`);
|
||||
|
||||
const cssModalCloseButton = styled(icon, `
|
||||
align-self: flex-end;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.modalBackdropCloseButtonFg};
|
||||
&:hover {
|
||||
--icon-color: ${theme.modalBackdropCloseButtonHoverFg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssModalContent = styled('div', `
|
||||
align-self: center;
|
||||
min-height: 0;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
`);
|
||||
|
||||
const cssModalImage = styled('img', `
|
||||
height: 100%;
|
||||
max-width: min(100%, 1200px);
|
||||
`);
|
||||
|
||||
const cssSpinner = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
`);
|
@ -0,0 +1,16 @@
|
||||
import {marked} from 'marked';
|
||||
|
||||
export const renderer = new marked.Renderer();
|
||||
|
||||
renderer.image = (href: string, text: string) => {
|
||||
return `<div class="doc-tutorial-popup-thumbnail">
|
||||
<img src="${href}" title="${text ?? ''}" />
|
||||
<div class="doc-tutorial-popup-thumbnail-icon-wrapper">
|
||||
<div class="doc-tutorial-popup-thumbnail-icon"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
renderer.link = (href: string, _title: string, text: string) => {
|
||||
return `<a href="${href}" target="_blank">${text}</a>`;
|
||||
};
|
@ -0,0 +1,26 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const config = {
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['allowFullscreen'],
|
||||
};
|
||||
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (node) => {
|
||||
if (!('target' in node)) { return; }
|
||||
|
||||
node.setAttribute('target', '_blank');
|
||||
});
|
||||
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
||||
if (data.tagName !== 'iframe') { return; }
|
||||
|
||||
const src = node.getAttribute('src');
|
||||
if (src?.startsWith('https://www.youtube.com/embed/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
return node.parentNode?.removeChild(node);
|
||||
});
|
||||
|
||||
export function sanitizeHTML(source: string | Node): string {
|
||||
return DOMPurify.sanitize(source, config);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import {MigrationInterface, QueryRunner, TableIndex} from "typeorm";
|
||||
|
||||
export class ForkIndexes1678737195050 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// HomeDBManager._onFork() references created_by in the ON clause.
|
||||
await queryRunner.createIndex("docs", new TableIndex({
|
||||
name: "docs__created_by",
|
||||
columnNames: ["created_by"]
|
||||
}));
|
||||
// HomeDBManager.getDocForks() references trunk_id in the WHERE clause.
|
||||
await queryRunner.createIndex("docs", new TableIndex({
|
||||
name: "docs__trunk_id",
|
||||
columnNames: ["trunk_id"]
|
||||
}));
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropIndex("docs", "docs__created_by");
|
||||
await queryRunner.dropIndex("docs", "docs__trunk_id");
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 563 B |
After Width: | Height: | Size: 615 B |
Binary file not shown.
Loading…
Reference in new issue