gristlabs_grist-core/app/client/widgets/AttachmentsEditor.ts

450 lines
14 KiB
TypeScript
Raw Normal View History

// 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 {MetaTableData} 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: MetaTableData<'_grist_Attachments'>;
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.getMetaTable('_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')!;
const fileType = mimeTypes.lookup(fileIdent) || 'application/octet-stream';
const filename: Observable<string> =
observable(this._attachmentsTable.getValue(val, 'fileName')!);
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(cellElem: Element) {
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')
),
),
this.options.readonly ? null : [
cssButton(cssButtonIcon('FieldAttachment'), 'Add',
dom.on('click', () => this._select()),
testId('pw-add')
),
dom.maybe(this._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, this.options.readonly)),
// Drag-over logic
(elem: HTMLElement) => dragOverClass(elem, cssDropping.className),
cssDragArea(this.options.readonly ? null : cssWarning('Drop files here to attach')),
this.options.readonly ? null : 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')!);
}
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, readonly: boolean): HTMLElement {
const commonArgs = [cssContent.cls(''), testId('pw-attachment-content')];
if (!att) {
return cssWarning('No attachments', readonly ? null : 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;
}
`);