// 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; hasPreview: boolean; url: Observable; } // 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 = observable(false); private _newName: Observable = observable(''); private _isRenameValid: Computed; private _rowIds: MutableObsArray; private _attachments: ObsArray; private _index: LiveIndex; private _selected: Computed; 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 = 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 = 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 { const uploadResult = await selectFiles({docWorkerUrl: this._docComm.docWorkerUrl, multiple: true, sizeLimit: 'attachment'}); return this._add(uploadResult); } private async _upload(files: FileList): Promise { const uploadResult = await uploadFiles(Array.from(files), {docWorkerUrl: this._docComm.docWorkerUrl, sizeLimit: 'attachment'}); return this._add(uploadResult); } private async _add(uploadResult: UploadResult|null): Promise { 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); } } }