You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/widgets/PreviewModal.ts

406 lines
13 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// External dependencies
import {computed, Computed, computedArray, Disposable} from 'grainjs';
import {MutableObsArray, obsArray, ObsArray, observable, Observable} from 'grainjs';
import {dom, input, LiveIndex, makeLiveIndex, noTestId, styled, TestId} from 'grainjs';
import noop = require('lodash/noop');
// Grist client libs
import {DocComm} from 'app/client/components/DocComm';
import {dragOverClass} from 'app/client/lib/dom';
import {selectFiles, uploadFiles} from 'app/client/lib/uploads';
import {DocData} from 'app/client/models/DocData';
import {TableData} from 'app/client/models/TableData';
import {button1Small, button1SmallBright} from 'app/client/ui/buttons';
import {cssModalBody, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals';
import {encodeQueryParams, mod} from 'app/common/gutil';
import {UploadResult} from 'app/common/uploads';
const modalPreview = styled('div', `
position: relative;
max-width: 80%;
margin: 0 auto;
background-color: white;
border: 1px solid #bbb;
`);
const modalPreviewImage = styled('img', `
display: block;
max-width: 100%;
max-height: 400px;
`);
const noPreview = styled('div', `
margin: 40% 0;
text-align: center;
vertical-align: top;
color: #bbb;
`);
const thumbnail = styled('div', `
position: relative;
height: 100%;
margin: 0 5px;
cursor: pointer;
`);
const thumbnailOuterRow = styled('div', `
display: flex;
position: relative;
height: 60px;
margin: 0 10px;
justify-content: center;
`);
const thumbnailInnerRow = styled('div', `
display: inline-flex;
height: 100%;
padding: 10px 0;
overflow-x: auto;
`);
const addButton = styled('div.glyphicon.glyphicon-paperclip', `
position: relative;
flex: 0 0 40px;
height: 40px;
width: 40px;
margin: 9px 10px;
padding: 12px 6px;
text-align: center;
vertical-align: middle;
border: 1px dashed #bbbbbb;
cursor: pointer;
font-size: 12pt;
`);
const addButtonPlus = styled('div.glyphicon.glyphicon-plus', `
position: absolute;
left: 9px;
top: 9px;
font-size: 5pt;
`);
const selectedOverlay = styled('div', `
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: #3390bf80;
`);
const noAttachments = styled('div', `
width: 100%;
height: 300px;
padding: 125px 0;
text-align: center;
border: 2px dashed #bbbbbb;
font-size: 12pt;
cursor: pointer;
`);
const menuBtnStyle = `
float: right;
margin: 0 5px;
cursor: pointer;
color: black;
text-decoration: none;
`;
const navArrowStyle = `
width: 10%;
height: 60px;
padding: 20px 0;
text-align: center;
cursor: pointer;
font-size: 14pt;
`;
interface Attachment {
rowId: number;
fileIdent: string;
filename: Observable<string>;
hasPreview: boolean;
url: Observable<string>;
}
// Used as a placeholder for the currently selected attachment if all attachments are removed.
const nullAttachment: Attachment = {
rowId: 0,
fileIdent: '',
filename: observable(''),
hasPreview: false,
url: observable('')
};
/**
* PreviewModal - Modal showing an attachment and options to rename, download or remove the
* attachment from the cell.
*/
export class PreviewModal extends Disposable {
private _attachmentsTable: TableData;
private _docComm: DocComm;
private _isEditingName: Observable<boolean> = observable(false);
private _newName: Observable<string> = observable('');
private _isRenameValid: Computed<boolean>;
private _rowIds: MutableObsArray<number>;
private _attachments: ObsArray<Attachment>;
private _index: LiveIndex;
private _selected: Computed<Attachment>;
constructor(
docData: DocData,
cellValue: number[],
testId: TestId = noTestId,
initRowId?: number,
onClose: (cellValue: any) => void = noop,
) {
super();
this._attachmentsTable = docData.getTable('_grist_Attachments')!;
this._docComm = docData.docComm;
this._rowIds = obsArray(Array.isArray(cellValue) ? cellValue.slice(1) : []);
this._attachments = computedArray(this._rowIds, (val: number): Attachment => {
const fileIdent: string = this._attachmentsTable.getValue(val, 'fileIdent') as string;
const filename: Observable<string> =
observable(this._attachmentsTable.getValue(val, 'fileName') as string);
return {
rowId: val,
fileIdent,
filename,
hasPreview: Boolean(this._attachmentsTable.getValue(val, 'imageHeight')),
url: computed((use) => this._getUrl(fileIdent, use(filename)))
};
});
this._index = makeLiveIndex(this, this._attachments,
initRowId ? this._rowIds.get().indexOf(initRowId) : 0);
this._selected = this.autoDispose(computed((use) => {
const index = use(this._index);
return index === null ? nullAttachment : use(this._attachments)[index];
}));
this._isRenameValid = this.autoDispose(computed((use) =>
Boolean(use(this._newName) && (use(this._newName) !== use(use(this._selected).filename)))
));
modal((ctl, owner) => {
owner.onDispose(() => { onClose(this.getCellValue()); });
return [
dom.style('padding', '16px'), // To match the previous style more closely.
cssModalTitle(
dom('span',
dom.text((use) => use(use(this._selected).filename) || 'No attachments'),
testId('modal-title'),
),
// This is a bootstrap-styled button, for now only to match previous style more closely.
dom('button', dom.cls('close'), '×', testId('modal-close-x'),
dom.on('click', () => ctl.close()),
)
),
cssModalBody(this._buildDom(testId, ctl)),
];
});
this.autoDispose(this._selected.addListener(att => { this._scrollToThumbnail(att.rowId); }));
// Initialize with the selected attachment's thumbnail in view.
if (initRowId) {
this._scrollToThumbnail(initRowId);
}
}
public getCellValue() {
const rowIds = this._rowIds.get() as any[];
return rowIds.length > 0 ? ["L"].concat(this._rowIds.get() as any[]) : '';
}
// Builds the attachment preview modal.
private _buildDom(testId: TestId, ctl: IModalControl): Element {
return dom('div',
// Prevent focus from switching away from the modal on mousedown.
dom.on('mousedown', (e) => e.preventDefault()),
dom.domComputed((use) =>
use(this._rowIds).length > 0 ? this._buildPreviewNav(testId, ctl) : this._buildEmptyMenu(testId)),
// Drag-over logic
dragOverClass('attachment_drag_over'),
dom.on('drop', ev => this._upload(ev.dataTransfer!.files)),
testId('modal')
);
}
private _buildPreviewNav(testId: TestId, ctl: IModalControl) {
const modalPrev = (selected: Attachment) => modalPreviewImage(
dom.attr('src', (use) => use(selected.url)),
testId('image')
);
const noPrev = noPreview({style: 'padding: 60px;'},
dom('div.glyphicon.glyphicon-file'),
dom('div', 'Preview not available')
);
return [
// Preview and left/right nav arrows
dom('div', {style: 'display: flex; align-items: center; height: 400px;'},
dom('div.glyphicon.glyphicon-chevron-left', {style: navArrowStyle},
dom.on('click', () => this._moveIndex(-1)),
testId('left')
),
modalPreview(
dom.maybe(this._selected, (selected) =>
dom.domComputed((use) => selected.hasPreview ? modalPrev(selected) : noPrev)
),
dom.attr('title', (use) => use(use(this._selected).filename)),
),
dom('div.glyphicon.glyphicon-chevron-right', {style: navArrowStyle},
dom.on('click', () => this._moveIndex(1)),
testId('right')
)
),
// Nav thumbnails
thumbnailOuterRow(
thumbnailInnerRow(
dom.forEach(this._attachments, (a: Attachment) => this._buildThumbnail(a)),
testId('thumbnails')
),
addButton(
addButtonPlus(),
dom.on('click', () => { this._select(); }), // tslint:disable-line:no-floating-promises TODO
testId('add')
)
),
// Menu buttons
dom('div', {style: 'height: 10px; margin: 10px;'},
dom('div.glyphicon.glyphicon-trash', {style: menuBtnStyle},
dom.on('click', () => this._remove()),
testId('remove')
),
dom('a.glyphicon.glyphicon-download-alt', {style: menuBtnStyle},
dom.attr('href', (use) => use(use(this._selected).url)),
dom.attr('target', '_blank'),
dom.attr('download', (use) => use(use(this._selected).filename)),
testId('download')
),
dom('div.glyphicon.glyphicon-pencil', {style: menuBtnStyle},
dom.on('click', () => this._isEditingName.set(!this._isEditingName.get())),
testId('rename')
)
),
// Rename menu
dom.maybe(this._isEditingName, () => {
this._newName.set(this._selected.get().filename.get());
return dom('div', {style: 'display: flex; margin: 20px 0 0 0;'},
input(this._newName, {onInput: true},
{style: 'flex: 4 1 0; margin: 0 5px;', placeholder: 'Rename file...'},
// Allow the input element to gain focus.
dom.on('mousedown', (e) => e.stopPropagation()),
dom.onKeyPress({Enter: () => this._renameFile()}),
// Prevent the dialog from losing focus and disposing on input completion.
dom.on('blur', (ev) => ctl.focus()),
dom.onDispose(() => ctl.focus()),
testId('rename-input')
),
button1Small('Cancel', {style: 'flex: 1 1 0; margin: 0 5px;'},
dom.on('click', () => this._isEditingName.set(false)),
testId('rename-cancel')
),
button1SmallBright('Save', {style: 'flex: 1 1 0; margin: 0 5px;'},
dom.on('click', () => this._renameFile()),
dom.boolAttr('disabled', (use) => !use(this._isRenameValid)),
testId('rename-save')
)
);
})
];
}
private _buildEmptyMenu(testId: TestId): Element {
return noAttachments('Click or drag to add attachments',
addButton({style: 'border: none; margin: 2px;'},
addButtonPlus(),
),
dom.on('click', () => { this._select(); }), // tslint:disable-line:no-floating-promises TODO
testId('empty-add')
);
}
private _buildThumbnail(att: Attachment): Element {
const isSelected: Computed<boolean> = computed((use) =>
att.rowId === use(this._selected).rowId);
return thumbnail({id: `thumbnail-${att.rowId}`, style: att.hasPreview ? '' : 'width: 30px;'},
dom.autoDispose(isSelected),
// TODO: Update to legitimately determine whether a file preview exists.
att.hasPreview ? dom('img', {style: 'height: 100%; vertical-align: top;'},
dom.attr('src', (use) => this._getUrl(att.fileIdent, use(att.filename)))
) : noPreview({style: 'width: 30px;'},
dom('div.glyphicon.glyphicon-file')
),
dom.maybe(isSelected, () => selectedOverlay()),
// Add a filename tooltip to the thumbnails.
dom.attr('title', (use) => use(att.filename)),
dom.style('border', (use) => use(isSelected) ? '1px solid #317193' : '1px solid #bbb'),
dom.on('click', () => {
this._index.set(this._attachments.get().findIndex(a => a.rowId === att.rowId));
})
);
}
private _getUrl(fileIdent: string, filename: string): string {
return this._docComm.docUrl('attachment') + '?' + encodeQueryParams({
...this._docComm.getUrlParams(),
ident: fileIdent,
name: filename
});
}
private _renameFile(): void {
const val = this._newName.get();
if (this._isRenameValid.get()) {
this._selected.get().filename.set(val);
const rowId = this._selected.get().rowId;
this._attachmentsTable.sendTableAction(['UpdateRecord', rowId, {fileName: val}]);
this._isEditingName.set(false);
}
}
private _moveIndex(dir: -1|1): void {
const len = this._attachments.get().length;
this._index.set(mod(this._index.get()! + dir, len));
}
private _scrollToThumbnail(rowId: number): void {
const tn = document.getElementById(`thumbnail-${rowId}`);
if (tn) {
tn.scrollIntoView();
}
}
// Removes the attachment being previewed from the cell (but not the document).
private _remove(): void {
this._rowIds.splice(this._index.get()!, 1);
}
private async _select(): Promise<void> {
const uploadResult = await selectFiles({docWorkerUrl: this._docComm.docWorkerUrl,
multiple: true, sizeLimit: 'attachment'});
return this._add(uploadResult);
}
private async _upload(files: FileList): Promise<void> {
const uploadResult = await uploadFiles(Array.from(files),
{docWorkerUrl: this._docComm.docWorkerUrl,
sizeLimit: 'attachment'});
return this._add(uploadResult);
}
private async _add(uploadResult: UploadResult|null): Promise<void> {
if (!uploadResult) { return; }
const rowIds = await this._docComm.addAttachments(uploadResult.uploadId);
const len = this._rowIds.get().length;
if (rowIds.length > 0) {
this._rowIds.push(...rowIds);
this._index.set(len);
}
}
}