import {Computed, dom, fromKo, input, makeTestId, onElem, styled, TestId} from 'grainjs'; import * as commands from 'app/client/components/commands'; import {dragOverClass} from 'app/client/lib/dom'; import {selectFiles, uploadFiles} from 'app/client/lib/uploads'; import {cssRow} from 'app/client/ui/RightPanelStyles'; import {colors, vars} from 'app/client/ui2018/cssVars'; import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget'; import {encodeQueryParams} from 'app/common/gutil'; import {MetaTableData} from 'app/client/models/TableData'; import {UploadResult} from 'app/common/uploads'; import {extname} from 'path'; import { SingleCell } from 'app/common/TableData'; const testId: TestId = makeTestId('test-pw-'); const attachmentWidget = styled('div.attachment_widget.field_clip', ` display: flex; flex-wrap: wrap; white-space: pre-wrap; `); const attachmentIcon = styled('div.attachment_icon.glyphicon.glyphicon-paperclip', ` position: absolute; top: 2px; left: 2px; padding: 2px; background-color: #D0D0D0; color: white; border-radius: 2px; border: none; cursor: pointer; box-shadow: 0 0 0 1px white; z-index: 1; &:hover { background-color: #3290BF; } `); const attachmentPreview = styled('div', ` color: black; background-color: white; border: 1px solid #bbb; margin: 0 2px 2px 0; position: relative; display: flex; align-items: center; justify-content: center; z-index: 0; &:hover { border-color: ${colors.lightGreen}; } `); const sizeLabel = styled('div', ` color: ${colors.slate}; margin-right: 9px; `); export interface SavingObservable extends ko.Observable { setAndSave(value: T): void; } /** * AttachmentsWidget - A widget for displaying attachments as image previews. */ export class AttachmentsWidget extends NewAbstractWidget { private _attachmentsTable: MetaTableData<'_grist_Attachments'>; private _height: SavingObservable; constructor(field: any) { super(field); // TODO: the Attachments table currently treated as metadata, and loaded on open, // but should probably be loaded on demand as it contains user data, which may be large. this._attachmentsTable = this._getDocData().getMetaTable('_grist_Attachments'); this._height = this.options.prop('height') as SavingObservable; this.autoDispose(this._height.subscribe(() => { this.field.viewSection().events.trigger('rowHeightChange'); })); } public buildDom(_row: any): Element { // NOTE: A cellValue of the correct type includes the list encoding designator 'L' as the // first element. const cellValue: SavingObservable = _row[this.field.colId()]; const values = Computed.create(null, fromKo(cellValue), (use, _cellValue) => Array.isArray(_cellValue) ? _cellValue.slice(1) : []); const colId = this.field.colId(); const tableId = this.field.column().table().tableId(); return attachmentWidget( dom.autoDispose(values), dragOverClass('attachment_drag_over'), attachmentIcon( dom.cls('attachment_hover_icon', (use) => use(values).length > 0), dom.on('click', () => this._selectAndSave(cellValue)) ), dom.maybe(_row.id, rowId => { return dom.forEach(values, (value: number) => isNaN(value) ? null : this._buildAttachment(value, values, { rowId, colId, tableId, })); }), dom.on('drop', ev => this._uploadAndSave(cellValue, ev.dataTransfer!.files)) ); } public buildConfigDom(): Element { const options = this.field.config.options; const height = options.prop('height'); const inputRange = input( fromKo(height), {onInput: true}, { style: 'margin: 0 5px;', type: 'range', min: '16', max: '96', value: '36' }, testId('thumbnail-size'), // When multiple columns are selected, we can only edit height when all // columns support it. dom.prop('disabled', use => use(options.disabled('height'))), ); // Save the height on change event (when the user releases the drag button) onElem(inputRange, 'change', (ev: any) => { height.setAndSave(ev.target.value).catch(reportError); }); return cssRow( sizeLabel('Size'), inputRange ); } protected _buildAttachment(value: number, allValues: Computed, cell: SingleCell): Element { const filename = this._attachmentsTable.getValue(value, 'fileName')!; const fileIdent = this._attachmentsTable.getValue(value, 'fileIdent')!; const height = this._attachmentsTable.getValue(value, 'imageHeight')!; const width = this._attachmentsTable.getValue(value, 'imageWidth')!; const hasPreview = Boolean(height); const ratio = hasPreview ? (width / height) : 1; return attachmentPreview({title: filename}, // Add a filename tooltip to the previews. dom.style('height', (use) => `${use(this._height)}px`), dom.style('width', (use) => `${parseInt(use(this._height), 10) * ratio}px`), // TODO: Update to legitimately determine whether a file preview exists. hasPreview ? dom('img', {style: 'height: 100%; min-width: 100%; vertical-align: top;'}, dom.attr('src', this._getUrl(value, cell)) ) : renderFileType(filename, fileIdent, this._height), // Open editor as if with input, using it to tell it which of the attachments to show. We // pass in a 1-based index. Hitting a key opens the cell, and this approach allows an // accidental feature of opening e.g. second attachment by hitting "2". dom.on('dblclick', () => commands.allCommands.input.run(String(allValues.get().indexOf(value) + 1))), testId('thumbnail'), ); } // Returns the attachment download url. private _getUrl(attId: number, cell: SingleCell): string { const docComm = this._getDocComm(); return docComm.docUrl('attachment') + '?' + encodeQueryParams({ ...docComm.getUrlParams(), ...cell, attId, name: this._attachmentsTable.getValue(attId, 'fileName') }); } private async _selectAndSave(value: SavingObservable): Promise { const uploadResult = await selectFiles({docWorkerUrl: this._getDocComm().docWorkerUrl, multiple: true, sizeLimit: 'attachment'}); return this._save(value, uploadResult); } private async _uploadAndSave(value: SavingObservable, files: FileList): Promise { const uploadResult = await uploadFiles(Array.from(files), {docWorkerUrl: this._getDocComm().docWorkerUrl, sizeLimit: 'attachment'}); return this._save(value, uploadResult); } private async _save(value: SavingObservable, uploadResult: UploadResult|null): Promise { if (!uploadResult) { return; } const rowIds = await this._getDocComm().addAttachments(uploadResult.uploadId); // Values should be saved with a leading "L" to fit Grist's list value encoding. const formatted: any[] = value() ? value() : ["L"]; value.setAndSave(formatted.concat(rowIds)); // Trigger a row height change in case the added attachment wraps to the next line. this.field.viewSection().events.trigger('rowHeightChange'); } } export function renderFileType(fileName: string, fileIdent: string, height?: ko.Observable): HTMLElement { // Prepend 'x' to ensure we return the extension even if the basename is empty (e.g. ".xls"). // Take slice(1) to strip off the leading period. const extension = extname('x' + fileName).slice(1) || extname('x' + fileIdent).slice(1) || '?'; return cssFileType(extension.toUpperCase(), height && cssFileType.cls((use) => { const size = parseFloat(use(height)); return size < 28 ? '-small' : size < 60 ? '-medium' : '-large'; }), ); } const cssFileType = styled('div', ` height: 100%; width: 100%; max-height: 80px; max-width: 80px; background-color: ${colors.slate}; display: flex; align-items: center; justify-content: center; font-size: ${vars.mediumFontSize}; font-weight: bold; color: white; overflow: hidden; &-small { font-size: ${vars.xxsmallFontSize}; } &-medium { font-size: ${vars.smallFontSize}; } &-large { font-size: ${vars.mediumFontSize}; } `);