mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
1654a2681f
Summary: This moves all client code to core, and makes minimal fix-ups to get grist and grist-core to compile correctly. The client works in core, but I'm leaving clean-up around the build and bundles to follow-up. Test Plan: existing tests pass; server-dev bundle looks sane Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2627
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);
|
||
}
|
||
}
|
||
}
|