mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
|
// 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<string>;
|
|||
|
hasPreview: boolean;
|
|||
|
url: Observable<string>;
|
|||
|
}
|
|||
|
|
|||
|
// 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<boolean> = observable(false);
|
|||
|
private _newName: Observable<string> = observable('');
|
|||
|
private _isRenameValid: Computed<boolean>;
|
|||
|
|
|||
|
private _rowIds: MutableObsArray<number>;
|
|||
|
private _attachments: ObsArray<Attachment>;
|
|||
|
private _index: LiveIndex;
|
|||
|
private _selected: Computed<Attachment>;
|
|||
|
|
|||
|
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<string> =
|
|||
|
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<boolean> = 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<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);
|
|||
|
}
|
|||
|
}
|
|||
|
}
|