(core) Revamp attachment editor + preview UI, and support more types, including PDFs.
Summary:
- New UI for the modal look mostly following the design prepared previously.
- Use <object> for rendering PDFs (on a Mac works on Firefox, Chrome, Safari; needs checking on Windows)
- While we are at it, use <video> and <audio> for relevant files (object would
render them too, but without the option to disable autoplay).
- Use <object> for unknown types, except for text/html (unsafe) and other text
types (need more work to render well).
- Fix skipping save on Escape or when attachments are unsaved (previously a
noop action was emitted, creating surprises with undo).
- Display extension for files without preview, both in-cell and in the modal.
- Replace tiny "eye" icon to preview particular attachment with double-clicking.
- As an accidental feature, a particular attachment can be previewed by typing 1, 2, 3, etc into cell.
- Renamed PreviewsWidget/PreviewModel to AttachmentsWidget/AttachmentsEditor.
Test Plan: Unified old and new tests for attachments, added new test cases.
Reviewers: paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D2667
2020-11-20 02:05:55 +00:00
|
|
|
// 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<string>;
|
|
|
|
|
|
|
|
// 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<string>;
|
|
|
|
|
|
|
|
// The inline URL of the attachment; served with Content-Disposition of "inline".
|
|
|
|
inlineUrl: Observable<string>;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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<number>;
|
|
|
|
private _attachments: ObsArray<Attachment>;
|
|
|
|
private _index: LiveIndex;
|
|
|
|
private _selected: Computed<Attachment|null>;
|
|
|
|
|
|
|
|
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<string> =
|
|
|
|
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.
|
2021-02-04 03:17:17 +00:00
|
|
|
public attach(cellElem: Element) {
|
(core) Revamp attachment editor + preview UI, and support more types, including PDFs.
Summary:
- New UI for the modal look mostly following the design prepared previously.
- Use <object> for rendering PDFs (on a Mac works on Firefox, Chrome, Safari; needs checking on Windows)
- While we are at it, use <video> and <audio> for relevant files (object would
render them too, but without the option to disable autoplay).
- Use <object> for unknown types, except for text/html (unsafe) and other text
types (need more work to render well).
- Fix skipping save on Escape or when attachments are unsaved (previously a
noop action was emitted, creating surprises with undo).
- Display extension for files without preview, both in-cell and in the modal.
- Replace tiny "eye" icon to preview particular attachment with double-clicking.
- As an accidental feature, a particular attachment can be previewed by typing 1, 2, 3, etc into cell.
- Renamed PreviewsWidget/PreviewModel to AttachmentsWidget/AttachmentsEditor.
Test Plan: Unified old and new tests for attachments, added new test cases.
Reviewers: paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D2667
2020-11-20 02:05:55 +00:00
|
|
|
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<void> {
|
|
|
|
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<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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
`);
|