diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index ee4e580e..841d52fa 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -47,9 +47,11 @@ export type ModalWidth = export function modal(createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg, options: IModalOptions = {}): void { function close() { - document.body.removeChild(modalDom); - // Ensure we run the disposers for the DOM contained in the modal. - dom.domDispose(modalDom); + if (modalDom.isConnected) { + document.body.removeChild(modalDom); + // Ensure we run the disposers for the DOM contained in the modal. + dom.domDispose(modalDom); + } } const modalDom = cssModalBacker( diff --git a/app/client/widgets/AttachmentsEditor.ts b/app/client/widgets/AttachmentsEditor.ts new file mode 100644 index 00000000..7ea16cf2 --- /dev/null +++ b/app/client/widgets/AttachmentsEditor.ts @@ -0,0 +1,447 @@ +// External dependencies +import {computed, Computed, computedArray} from 'grainjs'; +import {MutableObsArray, obsArray, ObsArray, observable, Observable} from 'grainjs'; +import {dom, LiveIndex, makeLiveIndex, styled} from 'grainjs'; + +// Grist client libs +import {DocComm} from 'app/client/components/DocComm'; +import {selectFiles, uploadFiles} from 'app/client/lib/uploads'; +import {DocData} from 'app/client/models/DocData'; +import {TableData} from 'app/client/models/TableData'; +import {basicButton, basicButtonLink, cssButtonGroup} from 'app/client/ui2018/buttons'; +import {colors, testId, vars} from 'app/client/ui2018/cssVars'; +import {editableLabel} from 'app/client/ui2018/editableLabel'; +import {icon} from 'app/client/ui2018/icons'; +import {IModalControl, modal} from 'app/client/ui2018/modals'; +import {renderFileType} from 'app/client/widgets/AttachmentsWidget'; +import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; +import {CellValue} from 'app/common/DocActions'; +import {clamp, encodeQueryParams} from 'app/common/gutil'; +import {UploadResult} from 'app/common/uploads'; +import * as mimeTypes from 'mime-types'; + +interface Attachment { + rowId: number; + + // Checksum of the file content, with extension, which identifies the file data in the _gristsys_Files table. + fileIdent: string; + + // MIME type of the data (looked up from fileIdent's extension). + fileType: string; + + // User-defined filename of the attachment. An observable to support renaming. + filename: Observable; + + // Whether the attachment is an image, indicated by the presence of imageHeight in the record. + hasPreview: boolean; + + // The download URL of the attachment; served with Content-Disposition of "attachment". + url: Observable; + + // The inline URL of the attachment; served with Content-Disposition of "inline". + inlineUrl: Observable; +} + +/** + * An AttachmentsEditor shows a full-screen modal with attachment previews, and options to rename, + * download, add or remove attachments in the edited cell. + */ +export class AttachmentsEditor extends NewBaseEditor { + private _attachmentsTable: TableData; + private _docComm: DocComm; + + private _rowIds: MutableObsArray; + private _attachments: ObsArray; + private _index: LiveIndex; + private _selected: Computed; + + constructor(options: Options) { + super(options); + + const docData: DocData = options.gristDoc.docData; + const cellValue: CellValue = options.cellValue; + + // editValue is abused slightly to indicate a 1-based index of the attachment. + const initRowIndex: number|undefined = (options.editValue && parseInt(options.editValue, 0) - 1) || 0; + + this._attachmentsTable = docData.getTable('_grist_Attachments')!; + this._docComm = docData.docComm; + + this._rowIds = obsArray(Array.isArray(cellValue) ? cellValue.slice(1) as number[] : []); + this._attachments = computedArray(this._rowIds, (val: number): Attachment => { + const fileIdent: string = this._attachmentsTable.getValue(val, 'fileIdent') as string; + const fileType = mimeTypes.lookup(fileIdent) || 'application/octet-stream'; + const filename: Observable = + observable(this._attachmentsTable.getValue(val, 'fileName') as string); + return { + rowId: val, + fileIdent, + fileType, + filename, + hasPreview: Boolean(this._attachmentsTable.getValue(val, 'imageHeight')), + url: computed((use) => this._getUrl(fileIdent, use(filename))), + inlineUrl: computed((use) => this._getUrl(fileIdent, use(filename), true)) + }; + }); + this._index = makeLiveIndex(this, this._attachments, initRowIndex); + this._selected = this.autoDispose(computed((use) => { + const index = use(this._index); + return index === null ? null : use(this._attachments)[index]; + })); + } + + // This "attach" is not about "attachments", but about attaching this widget to the page DOM. + public attach(cellRect: ClientRect|DOMRect) { + modal((ctl, owner) => { + // If FieldEditor is disposed externally (e.g. on navigation), be sure to close the modal. + this.onDispose(ctl.close); + return [ + cssFullScreenModal.cls(''), + dom.onKeyDown({ + Enter: (ev) => { ctl.close(); this.options.commands.fieldEditSaveHere(); }, + Escape: (ev) => { ctl.close(); this.options.commands.fieldEditCancel(); }, + ArrowLeft$: (ev) => !isInEditor(ev) && this._moveIndex(-1), + ArrowRight$: (ev) => !isInEditor(ev) && this._moveIndex(1), + }), + // Close if clicking into the background. (The default modal's behavior for this isn't + // triggered because our content covers the whole screen.) + dom.on('click', (ev, elem) => { if (ev.target === elem) { ctl.close(); }}), + ...this._buildDom(ctl) + ]; + }, {noEscapeKey: true}); + } + + public getCellValue() { + return ["L", ...this._rowIds.get()] as CellValue; + } + + public getCursorPos(): number { + return 0; + } + + public getTextValue(): string { + return ''; + } + + // Builds the attachment preview modal. + private _buildDom(ctl: IModalControl) { + return [ + cssHeader( + cssFlexExpand(dom.text(use => { + const len = use(this._attachments).length; + return len ? `${(use(this._index) || 0) + 1} of ${len}` : ''; + }), + testId('pw-counter') + ), + dom.maybe(this._selected, selected => + cssTitle( + cssEditableLabel(selected.filename, (val) => this._renameAttachment(selected, val), + testId('pw-name')) + ) + ), + cssFlexExpand( + cssFileButtons( + dom.maybe(this._selected, selected => + basicButtonLink(cssButton.cls(''), cssButtonIcon('Download'), 'Download', + dom.attr('href', selected.url), + dom.attr('target', '_blank'), + dom.attr('download', selected.filename), + testId('pw-download') + ), + ), + cssButton(cssButtonIcon('FieldAttachment'), 'Add', + dom.on('click', () => this._select()), + testId('pw-add') + ), + dom.maybe(this._selected, selected => + cssButton(cssButtonIcon('Remove'), 'Delete', + dom.on('click', () => this._remove()), + testId('pw-remove') + ), + ) + ), + cssCloseButton(cssBigIcon('CrossBig'), dom.on('click', () => ctl.close()), + testId('pw-close')), + ) + ), + cssNextArrow(cssNextArrow.cls('-left'), cssBigIcon('Expand'), testId('pw-left'), + dom.hide(use => !use(this._attachments).length || use(this._index) === 0), + dom.on('click', () => this._moveIndex(-1)) + ), + cssNextArrow(cssNextArrow.cls('-right'), cssBigIcon('Expand'), testId('pw-right'), + dom.hide(use => !use(this._attachments).length || use(this._index) === use(this._attachments).length - 1), + dom.on('click', () => this._moveIndex(1)) + ), + dom.domComputed(this._selected, selected => renderContent(selected)), + + // Drag-over logic + (elem: HTMLElement) => dragOverClass(elem, cssDropping.className), + cssDragArea(cssWarning('Drop files here to attach')), + dom.on('drop', ev => this._upload(ev.dataTransfer!.files)), + testId('pw-modal') + ]; + } + + private async _renameAttachment(att: Attachment, fileName: string): Promise { + await this._attachmentsTable.sendTableAction(['UpdateRecord', att.rowId, {fileName}]); + // Update the observable, since it's not on its own observing changes. + att.filename.set(this._attachmentsTable.getValue(att.rowId, 'fileName') as string); + } + + private _getUrl(fileIdent: string, filename: string, inline?: boolean): string { + return this._docComm.docUrl('attachment') + '?' + encodeQueryParams({ + ...this._docComm.getUrlParams(), + ident: fileIdent, + name: filename, + ...(inline ? {inline: 1} : {}) + }); + } + + private _moveIndex(dir: -1|1): void { + const next = this._index.get()! + dir; + this._index.set(clamp(next, 0, this._attachments.get().length)); + } + + // 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); + } + } +} + +function isInEditor(ev: KeyboardEvent): boolean { + return (ev.target as HTMLElement).tagName === 'INPUT'; +} + +function renderContent(att: Attachment|null): HTMLElement { + const commonArgs = [cssContent.cls(''), testId('pw-attachment-content')]; + if (!att) { + return cssWarning('No attachments', cssDetails('Drop files here to attach.'), ...commonArgs); + } else if (att.hasPreview) { + return dom('img', dom.attr('src', att.url), ...commonArgs); + } else if (att.fileType.startsWith('video/')) { + return dom('video', dom.attr('src', att.inlineUrl), {autoplay: false, controls: true}, ...commonArgs); + } else if (att.fileType.startsWith('audio/')) { + return dom('audio', dom.attr('src', att.inlineUrl), {autoplay: false, controls: true}, ...commonArgs); + } else if (att.fileType.startsWith('text/') || att.fileType === 'application/json') { + // Rendering text/html is risky. Things like text/plain and text/csv we could render though, + // but probably not using object tag (which needs work to look acceptable). + return dom('div', ...commonArgs, + cssWarning(cssContent.cls(''), renderFileType(att.filename.get(), att.fileIdent), + cssDetails('Preview not available.'))); + } else { + // Setting 'type' attribute is important to avoid a download prompt from Chrome. + return dom('object', {type: att.fileType}, dom.attr('data', att.inlineUrl), ...commonArgs, + cssWarning(cssContent.cls(''), renderFileType(att.filename.get(), att.fileIdent), + cssDetails('Preview not available.')) + ); + } +} + +function dragOverClass(target: HTMLElement, className: string): void { + let enterTarget: EventTarget|null = null; + function toggle(ev: DragEvent, onOff: boolean) { + enterTarget = onOff ? ev.target : null; + ev.stopPropagation(); + ev.preventDefault(); + target.classList.toggle(className, onOff); + } + dom.onElem(target, 'dragenter', (ev) => toggle(ev, true)); + dom.onElem(target, 'dragleave', (ev) => (ev.target === enterTarget) && toggle(ev, false)); + dom.onElem(target, 'drop', (ev) => toggle(ev, false)); +} + +const cssFullScreenModal = styled('div', ` + background-color: initial; + width: 100%; + height: 100%; + border: none; + border-radius: 0px; + box-shadow: none; + padding: 0px; +`); + +const cssHeader = styled('div', ` + padding: 16px 24px; + position: fixed; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + color: white; +`); + +const cssCloseButton = styled('div', ` + padding: 6px; + border-radius: 32px; + cursor: pointer; + background-color: ${colors.light}; + --icon-color: ${colors.lightGreen}; + + &:hover { + background-color: ${colors.mediumGreyOpaque}; + --icon-color: ${colors.darkGreen}; + } +`); + +const cssBigIcon = styled(icon, ` + padding: 10px; +`); + +const cssTitle = styled('div', ` + display: inline-block; + padding: 8px 16px; + margin-right: 8px; + min-width: 0px; + overflow: hidden; + + &:hover { + outline: 1px solid ${colors.slate}; + } + &:focus-within { + outline: 1px solid ${colors.darkGreen}; + } +`); + +const cssEditableLabel = styled(editableLabel, ` + font-size: ${vars.mediumFontSize}; + font-weight: bold; + color: white; +`); + +const cssFlexExpand = styled('div', ` + flex: 1; + display: flex; +`); + +const cssFileButtons = styled(cssButtonGroup, ` + margin-left: auto; + margin-right: 16px; + height: 32px; + flex: none; +`); + +const cssButton = styled(basicButton, ` + background-color: ${colors.light}; + font-weight: normal; + padding: 0 16px; + border-top: none; + border-right: none; + border-bottom: none; + border-left: 1px solid ${colors.darkGrey}; + display: flex; + align-items: center; + + &:first-child { + border: none; + } + &:hover { + background-color: ${colors.mediumGreyOpaque}; + border-color: ${colors.darkGrey}; + } +`); + +const cssButtonIcon = styled(icon, ` + --icon-color: ${colors.slate}; + margin-right: 4px; +`); + +const cssNextArrow = styled('div', ` + position: fixed; + height: 32px; + margin: auto 24px; + top: 0px; + bottom: 0px; + z-index: 1; + + padding: 6px; + border-radius: 32px; + cursor: pointer; + background-color: ${colors.lightGreen}; + --icon-color: ${colors.light}; + + &:hover { + background-color: ${colors.darkGreen}; + } + &-left { + transform: rotateY(180deg); + left: 0px; + } + &-right { + right: 0px; + } +`); + +const cssDropping = styled('div', ''); + +const cssContent = styled('div', ` + display: block; + height: calc(100% - 72px); + width: calc(100% - 64px); + max-width: 800px; + margin-left: auto; + margin-right: auto; + margin-top: 64px; + margin-bottom: 8px; + outline: none; + img& { + width: max-content; + height: unset; + } + audio& { + padding-bottom: 64px; + } + .${cssDropping.className} > & { + display: none; + } +`); + +const cssWarning = styled('div', ` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-size: ${vars.mediumFontSize}; + font-weight: bold; + color: white; + padding: 0px; +`); + +const cssDetails = styled('div', ` + font-weight: normal; + margin-top: 24px; +`); + +const cssDragArea = styled(cssContent, ` + border: 2px dashed ${colors.mediumGreyOpaque}; + height: calc(100% - 96px); + margin-top: 64px; + padding: 0px; + justify-content: center; + display: none; + .${cssDropping.className} > & { + display: flex; + } +`); diff --git a/app/client/widgets/PreviewsWidget.css b/app/client/widgets/AttachmentsWidget.css similarity index 100% rename from app/client/widgets/PreviewsWidget.css rename to app/client/widgets/AttachmentsWidget.css diff --git a/app/client/widgets/PreviewsWidget.ts b/app/client/widgets/AttachmentsWidget.ts similarity index 71% rename from app/client/widgets/PreviewsWidget.ts rename to app/client/widgets/AttachmentsWidget.ts index 488aab43..5feab2b8 100644 --- a/app/client/widgets/PreviewsWidget.ts +++ b/app/client/widgets/AttachmentsWidget.ts @@ -1,15 +1,15 @@ 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/RightPanel'; -import {colors} from 'app/client/ui2018/cssVars'; +import {colors, vars} from 'app/client/ui2018/cssVars'; import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget'; -import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor'; -import {PreviewModal} from 'app/client/widgets/PreviewModal'; import {encodeQueryParams} from 'app/common/gutil'; import {TableData} from 'app/common/TableData'; import {UploadResult} from 'app/common/uploads'; +import {extname} from 'path'; const testId: TestId = makeTestId('test-pw-'); @@ -43,42 +43,15 @@ const attachmentPreview = styled('div', ` border: 1px solid #bbb; margin: 0 2px 2px 0; position: relative; - vertical-align: top; + display: flex; + align-items: center; + justify-content: center; z-index: 0; - - &:hover > .show_preview { - display: block; - } -`); - -const showPreview = styled('div.glyphicon.glyphicon-eye-open.show_preview', ` - position: absolute; - display: none; - right: -2px; - top: -2px; - width: 12px; - height: 12px; - border-radius: 2px; - background-color: #888; - color: white; - font-size: 6pt; - padding: 2px; - cursor: pointer; - &:hover { - display: block; + border-color: ${colors.lightGreen}; } `); -const noPreviewIcon = styled('div.glyphicon.glyphicon-file', ` - min-width: 100%; - color: #bbb; - text-align: center; - margin: calc(50% * 1.33 - 6px) 0; - font-size: 6pt; - vertical-align: top; -`); - const sizeLabel = styled('div', ` color: ${colors.slate}; margin-right: 9px; @@ -89,9 +62,9 @@ export interface SavingObservable extends ko.Observable { } /** - * PreviewsWidget - A widget for displaying attachments as image previews. + * AttachmentsWidget - A widget for displaying attachments as image previews. */ -export class PreviewsWidget extends NewAbstractWidget { +export class AttachmentsWidget extends NewAbstractWidget { private _attachmentsTable: TableData; private _height: SavingObservable; @@ -126,7 +99,7 @@ export class PreviewsWidget extends NewAbstractWidget { dom.on('click', () => this._selectAndSave(cellValue)) ), dom.forEach(values, (value: number) => - isNaN(value) ? null : this._buildAttachment(value, cellValue) + isNaN(value) ? null : this._buildAttachment(value, values) ), dom.on('drop', ev => this._uploadAndSave(cellValue, ev.dataTransfer!.files)) ); @@ -148,12 +121,13 @@ export class PreviewsWidget extends NewAbstractWidget { ); } - protected _buildAttachment(value: number, cellValue: SavingObservable): Element { + protected _buildAttachment(value: number, allValues: Computed): Element { const filename: string = this._attachmentsTable.getValue(value, 'fileName') as string; + const fileIdent: string = this._attachmentsTable.getValue(value, 'fileIdent') as string; const height: number = this._attachmentsTable.getValue(value, 'imageHeight') as number; const width: number = this._attachmentsTable.getValue(value, 'imageWidth') as number; const hasPreview: boolean = Boolean(height); - const ratio: number = hasPreview ? (width / height) : .75; + const ratio: number = hasPreview ? (width / height) : 1; return attachmentPreview({title: filename}, // Add a filename tooltip to the previews. dom.style('height', (use) => `${use(this._height)}px`), @@ -161,13 +135,12 @@ export class PreviewsWidget extends NewAbstractWidget { // 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)) - ) : noPreviewIcon(), - dom.cls('no_preview', () => !hasPreview), - showPreview( - dom.on('click', () => this._showPreview(value, cellValue)), - testId(`preview-${value}`) - ), - testId(String(value)) + ) : 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'), ); } @@ -208,40 +181,35 @@ export class PreviewsWidget extends NewAbstractWidget { // Trigger a row height change in case the added attachment wraps to the next line. this.field.viewSection().events.trigger('rowHeightChange'); } - - // Show a preview for the attachment with the given rowId value in the attachments table. - private _showPreview(value: number, cellValue: SavingObservable): void { - // Modal should be disposed on close. - const onClose = (_cellValue: number[]) => { - cellValue.setAndSave(_cellValue); - modal.dispose(); - }; - const modal = PreviewModal.create(this, this._getDocData(), cellValue.peek(), testId, value, - onClose); - } } -/** - * A PreviewsEditor is just the PreviewModal, which allows adding and removing attachments. - */ -export class PreviewsEditor extends NewBaseEditor { - private _modal: any; - - public attach(cellRect: ClientRect|DOMRect) { - const docData = this.options.gristDoc.docData; - // Disposal on close is handled by the FieldBuilder editor logic. - this._modal = PreviewModal.create(this, docData, this.options.cellValue as number[], testId); - } - - public getCellValue(): any { - return this._modal.getCellValue(); - } - - public getCursorPos(): number { - return 0; - } - - public getTextValue(): string { - return ''; - } +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}; } +`); diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index ad5964a2..6c80e7e9 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -11,6 +11,7 @@ import {CellValue} from "app/common/DocActions"; import {isRaisedException} from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; import {Disposable, Holder, Observable} from 'grainjs'; +import isEqual = require('lodash/isEqual'); type IEditorConstructor = typeof NewBaseEditor; interface ICommandGroup { [cmd: string]: () => void; } @@ -38,7 +39,7 @@ export function saveWithoutEditor( // Set the given field of editRow to value, only if different from the current value of the cell. export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, value: CellValue): Promise { const obs = editRow.cells[field.colId()]; - if (value !== obs.peek()) { + if (!isEqual(value, obs.peek())) { return obs.setAndSave(value); } } diff --git a/app/client/widgets/PreviewModal.ts b/app/client/widgets/PreviewModal.ts deleted file mode 100644 index 54ee5dd9..00000000 --- a/app/client/widgets/PreviewModal.ts +++ /dev/null @@ -1,405 +0,0 @@ -// 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); - } - } -} diff --git a/app/client/widgets/UserType.js b/app/client/widgets/UserType.js index dfd0c3c7..d748ea80 100644 --- a/app/client/widgets/UserType.js +++ b/app/client/widgets/UserType.js @@ -221,16 +221,16 @@ var typeDefs = { label: 'Attachment', icon: 'FieldAttachment', widgets: { - Previews: { - cons: 'PreviewsWidget', - editCons: 'PreviewsEditor', + Attachments: { + cons: 'AttachmentsWidget', + editCons: 'AttachmentsEditor', icon: 'FieldAttachment', options: { height: '36' } } }, - default: 'Previews' + default: 'Attachments' } }; exports.typeDefs = typeDefs; diff --git a/app/client/widgets/UserTypeImpl.js b/app/client/widgets/UserTypeImpl.js index 39710c42..9732badc 100644 --- a/app/client/widgets/UserTypeImpl.js +++ b/app/client/widgets/UserTypeImpl.js @@ -1,7 +1,8 @@ const {NTextBox} = require('./NTextBox'); const {NumericTextBox} = require('./NumericTextBox'); const {Spinner} = require('./Spinner'); -const {PreviewsWidget, PreviewsEditor} = require('./PreviewsWidget'); +const {AttachmentsWidget} = require('./AttachmentsWidget'); +const {AttachmentsEditor} = require('./AttachmentsEditor'); const UserType = require('./UserType'); const {HyperLinkEditor} = require('./HyperLinkEditor'); const {NTextEditor} = require('./NTextEditor'); @@ -27,8 +28,8 @@ const nameToWidget = { 'DateTimeTextBox': require('./DateTimeTextBox'), 'DateTextBox': require('./DateTextBox'), 'DateEditor': require('./DateEditor'), - 'PreviewsWidget': PreviewsWidget, - 'PreviewsEditor': PreviewsEditor, + 'AttachmentsWidget': AttachmentsWidget, + 'AttachmentsEditor': AttachmentsEditor, 'DateTimeEditor': require('./DateTimeEditor'), };