mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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
This commit is contained in:
		
							parent
							
								
									c387fc4bce
								
							
						
					
					
						commit
						2e22966289
					
				@ -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(
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										447
									
								
								app/client/widgets/AttachmentsEditor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										447
									
								
								app/client/widgets/AttachmentsEditor.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<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.
 | 
			
		||||
  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<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;
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
@ -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<T> extends ko.Observable<T> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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<string>;
 | 
			
		||||
@ -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<number[]>): Element {
 | 
			
		||||
  protected _buildAttachment(value: number, allValues: Computed<number[]>): 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<number[]>): 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<string>): 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}; }
 | 
			
		||||
`);
 | 
			
		||||
@ -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<void> {
 | 
			
		||||
  const obs = editRow.cells[field.colId()];
 | 
			
		||||
  if (value !== obs.peek()) {
 | 
			
		||||
  if (!isEqual(value, obs.peek())) {
 | 
			
		||||
    return obs.setAndSave(value);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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<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);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
@ -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'),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user