@eclipse-scout/core
Version:
Eclipse Scout runtime
437 lines (396 loc) • 15.6 kB
text/typescript
/*
* Copyright (c) 2010, 2023 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {arrays, BlobWithName, ClipboardFieldModel, Device, DragAndDropOptions, FatalMessageOptions, InputFieldKeyStrokeContext, keys, KeyStrokeContext, mimeTypes, scout, Session, strings, ValueField} from '../../../index';
import $ from 'jquery';
export class ClipboardField extends ValueField<string> implements ClipboardFieldModel {
declare model: ClipboardFieldModel;
allowedMimeTypes: string[];
maximumSize: number;
readOnly: boolean;
protected _fileUploadWaitRetryCountTimeout: number;
protected _fullSelectionLength: number;
constructor() {
super();
this.allowedMimeTypes = null;
this.maximumSize = null;
this._fileUploadWaitRetryCountTimeout = 99;
this._fullSelectionLength = 0;
}
// Keys that don't alter the content of a text field and are therefore always allowed in the clipboard field
static NON_DESTRUCTIVE_KEYS = [
// Default form handling
keys.ESC,
keys.ENTER,
keys.TAB,
// Navigate and mark text
keys.PAGE_UP,
keys.PAGE_DOWN,
keys.END,
keys.HOME,
keys.LEFT,
keys.UP,
keys.RIGHT,
keys.DOWN,
// Browser hotkeys (e.g. developer tools)
keys.F1,
keys.F2,
keys.F3,
keys.F4,
keys.F5,
keys.F6,
keys.F7,
keys.F8,
keys.F9,
keys.F10,
keys.F11,
keys.F12
];
// Keys that always alter the content of a text field, independent from the modifier keys
static ALWAYS_DESTRUCTIVE_KEYS = [
keys.BACKSPACE,
keys.DELETE
];
protected override _createKeyStrokeContext(): KeyStrokeContext {
return new InputFieldKeyStrokeContext();
}
protected override _render() {
// We don't use makeDiv() here intentionally because the DIV created must
// not have the 'unselectable' attribute.
this.addContainer(this.$parent, 'clipboard-field');
this.addLabel();
this.addField(this.$parent.makeElement('<div>').addClass('input-field'));
this.addStatus();
this.$field
.disableSpellcheck()
.attr('contenteditable', 'true')
.attr('tabindex', '0')
.on('keydown', this._onKeyDown.bind(this))
.on('input', this._onInput.bind(this))
.on('paste', this._onPaste.bind(this))
.on('copy', this._onCopy.bind(this))
.on('cut', this._onCopy.bind(this));
}
protected override _getDragAndDropHandlerOptions(): DragAndDropOptions {
let options = super._getDragAndDropHandlerOptions();
options.allowedTypes = () => this.allowedMimeTypes;
// use the smaller property (this.maximumSize for backwards compatibility) but ignore null values which would result in a maximum size of zero.
options.dropMaximumSize = () => Math.min(scout.nvl(this.dropMaximumSize, Number.MAX_VALUE), scout.nvl(this.maximumSize, Number.MAX_VALUE));
return options;
}
protected override _renderDisplayText() {
let displayText = this.displayText;
let img: HTMLElement;
this.$field.children().each((idx, elem) => {
if (!img && elem.nodeName === 'IMG') {
img = elem;
}
});
if (strings.hasText(displayText)) {
this.$field.html(strings.nl2br(displayText, true));
this._installScrollbars();
setTimeout(() => {
this.$field.selectAllText();
// store length of full selection, in order to determine if the whole text is selected in "onCopy"
let selection = this._getSelection();
this._fullSelectionLength = (selection) ? selection.toString().length : 0;
});
} else {
this.$field.empty();
}
// restore old img for firefox upload mechanism.
if (img) {
this.$field.prepend(img);
}
}
protected _getSelection(): Selection {
let selection: Selection,
myWindow = this.$container.window(true);
if (myWindow.getSelection) {
selection = myWindow.getSelection();
} else if (document.getSelection) {
selection = document.getSelection();
}
if (!selection || selection.toString().length === 0) {
return null;
}
return selection;
}
protected _onKeyDown(event: JQuery.KeyDownEvent): boolean {
if (scout.isOneOf(event.which, ClipboardField.ALWAYS_DESTRUCTIVE_KEYS)) {
return false; // never allowed
}
if (event.ctrlKey || event.altKey || event.metaKey || scout.isOneOf(event.which, ClipboardField.NON_DESTRUCTIVE_KEYS)) {
return; // allow bubble to other event handlers
}
// do not allow to enter something manually
return false;
}
protected _onInput(event: JQuery.TriggeredEvent): boolean {
// if the user somehow managed to fire to input something (e.g. "delete" menu in FF & IE), just reset the value to the previous content
this._renderDisplayText();
return false;
}
protected _onCopy(event: JQuery.TriggeredEvent): boolean {
if (Device.get().isIos() && this._onIosCopy(event) === false) {
return;
}
let text: string,
dataTransfer: DataTransfer,
myWindow = this.$container.window(true);
try {
let originalEvent = event.originalEvent as ClipboardEvent;
if (originalEvent.clipboardData) {
dataTransfer = originalEvent.clipboardData;
} else if (myWindow['clipboardData']) {
dataTransfer = myWindow['clipboardData'];
}
} catch (e) {
// Because windows forbids concurrent access to the clipboard, a possible exception is thrown on 'myWindow.clipboardData'
// (see Remarks on https://msdn.microsoft.com/en-us/library/windows/desktop/ms649048(v=vs.85).aspx)
// Because of this behavior a failed access will just be logged but not presented to the user.
$.log.error('Error while reading "clipboardData"', e);
}
if (!dataTransfer) {
$.log.error('Unable to access clipboard data.');
return false;
}
// scroll bar must not be in field when copying
this._uninstallScrollbars();
let selection = this._getSelection();
if (!selection) {
return;
}
// if the length of the selection is equals to the length of the (initial) full selection
// use the internal 'displayText' value because some browsers are collapsing white spaces
// which lead to problems when coping data form tables with empty cells ("\t\t").
if (selection.toString().length === this._fullSelectionLength) {
text = this.displayText;
} else {
text = selection.toString();
}
try {
// Chrome, Firefox - causes an exception in IE
dataTransfer.setData('text/plain', text);
} catch (e) {
// IE, see https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/
dataTransfer.setData('Text', text);
}
// (re)install scroll bars
this._installScrollbars();
return false;
}
protected _onIosCopy(event: JQuery.TriggeredEvent): boolean {
// Setting custom clipboard data is not possible with iOS due to a WebKit bug.
// The default behavior copies rich text. Since it is not expected to copy the style of the clipboard field, temporarily set color and background-color
// https://bugs.webkit.org/show_bug.cgi?id=176980
let oldStyle = this.$field.attr('style');
this.$field.css({
'color': '#000',
'background-color': 'transparent'
});
setTimeout(() => {
this.$field.attrOrRemove('style', oldStyle);
});
return false;
}
protected _onPaste(event: JQuery.TriggeredEvent): boolean {
if (this.readOnly) {
// Prevent pasting in "copy" mode
return false;
}
let startPasteTimestamp = Date.now();
let dataTransfer: DataTransfer,
myWindow = this.$container.window(true);
this.$field.selectAllText();
let originalEvent = event.originalEvent as ClipboardEvent;
if (originalEvent.clipboardData) {
dataTransfer = originalEvent.clipboardData;
} else if (myWindow['clipboardData']) {
dataTransfer = myWindow['clipboardData'];
} else {
// unable to obtain data transfer object
throw new Error('Unable to access clipboard data.');
}
let filesArgument: BlobWithName[] = [], // options to be uploaded, arguments for this.session.uploadFiles
additionalOptions: Record<string, string> = {},
additionalOptionsCompatibilityIndex = 0, // counter for additional options
contentCount = 0;
// some browsers (e.g. IE) specify text content simply as data of type 'Text', it is not listed in list of types
let textContent = dataTransfer.getData('Text');
if (textContent) {
if (window.Blob) {
filesArgument.push(new Blob([textContent], {
type: mimeTypes.TEXT_PLAIN
}));
contentCount++;
} else {
// compatibility workaround
additionalOptions['textTransferObject' + additionalOptionsCompatibilityIndex++] = textContent;
contentCount++;
}
}
if (contentCount === 0 && dataTransfer.items) {
Array.prototype.forEach.call(dataTransfer.items, (item: DataTransferItem) => {
if (item.type === mimeTypes.TEXT_PLAIN) {
item.getAsString(str => {
filesArgument.push(new Blob([str], {
type: mimeTypes.TEXT_PLAIN
}));
contentCount++;
});
} else if (scout.isOneOf(item.type, [mimeTypes.IMAGE_PNG, mimeTypes.IMAGE_JPG, mimeTypes.IMAGE_JPEG, mimeTypes.IMAGE_GIF])) {
let file: BlobWithName & File = item.getAsFile();
if (file) {
// When pasting an image from the clipboard, Chrome and Firefox create a File object with
// a generic name such as "image.png" or "grafik.png" (hardcoded in Chrome, locale-dependent
// in FF). It is therefore not possible to distinguish between a real file and a bitmap
// from the clipboard. The following code measures the time between the start of the paste
// event and the file's last modified timestamp. If it is "very small", the file is likely
// a bitmap from the clipboard and not a real file. In that case, add a special "scoutName"
// attribute to the file object that is then used as a filename in session.uploadFiles().
let lastModifiedDiff = startPasteTimestamp - file.lastModified;
if (lastModifiedDiff < 1000) {
file.scoutName = Session.EMPTY_UPLOAD_FILENAME;
}
filesArgument.push(file);
contentCount++;
}
}
});
this._cleanupFiles(filesArgument);
}
let waitForFileReaderEvents = 0;
if (contentCount === 0 && dataTransfer.files) {
Array.prototype.forEach.call(dataTransfer.files, (item: File) => {
let reader = new FileReader();
// register functions for file reader
reader.onload = event => {
let f: BlobWithName = new Blob([event.target.result], {
type: item.type
});
f.name = item.name;
filesArgument.push(f);
waitForFileReaderEvents--;
};
reader.onerror = event => {
waitForFileReaderEvents--;
$.log.error('Error while reading file ' + item.name + ' / ' + event.target.error.code);
};
// start file reader
waitForFileReaderEvents++;
contentCount++;
reader.readAsArrayBuffer(item);
});
}
// upload function needs to be called asynchronously to support real files
let uploadFunctionTimeoutCount = 0;
let uploadFunction = () => {
if (waitForFileReaderEvents !== 0 && uploadFunctionTimeoutCount++ !== this._fileUploadWaitRetryCountTimeout) {
setTimeout(uploadFunction, 150);
return;
}
if (uploadFunctionTimeoutCount >= this._fileUploadWaitRetryCountTimeout) {
let boxOptions: FatalMessageOptions = {
entryPoint: this.$container.entryPoint(),
header: this.session.text('ui.ClipboardTimeoutTitle'),
body: this.session.text('ui.ClipboardTimeout'),
yesButtonText: this.session.text('Ok')
};
this.session.showFatalMessage(boxOptions);
return;
}
// upload paste event as files
if (filesArgument.length > 0 || Object.keys(additionalOptions).length > 0) {
this.session.uploadFiles(this, filesArgument, additionalOptions, this.maximumSize, this.allowedMimeTypes);
}
};
// upload content function, if content can not be read from event
// (e.g. "Allow programmatic clipboard access" is disabled in IE)
let uploadContentFunction = () => {
// store old inner html (will be replaced)
this._uninstallScrollbars();
let oldHtmlContent = this.$field.html();
this.$field.html('');
let restoreOldHtmlContent = () => {
this.$field.html(oldHtmlContent);
this._installScrollbars();
};
setTimeout(() => {
let imgElementsFound = false;
this.$field.children().each((idx, elem) => {
if (elem.nodeName === 'IMG') {
let srcAttr = $(elem).attr('src');
let srcDataMatch = /^data:(.*);base64,(.*)/.exec(srcAttr);
let mimeType = srcDataMatch && srcDataMatch[1];
if (scout.isOneOf(mimeType, mimeTypes.IMAGE_PNG, mimeTypes.IMAGE_JPG, mimeTypes.IMAGE_JPEG, mimeTypes.IMAGE_GIF)) {
let encData = window.atob(srcDataMatch[2]); // base64 decode
let byteNumbers = [];
for (let i = 0; i < encData.length; i++) {
byteNumbers[i] = encData.charCodeAt(i);
}
let byteArray = new Uint8Array(byteNumbers);
let f: BlobWithName = new Blob([byteArray], {
type: mimeType
});
f.name = '';
filesArgument.push(f);
imgElementsFound = true;
}
}
});
if (imgElementsFound) {
restoreOldHtmlContent();
} else {
// try to read natively pasted text from field
let nativePasteContent = this.$field.text();
if (strings.hasText(nativePasteContent)) {
this.setDisplayText(nativePasteContent);
filesArgument.push(new Blob([nativePasteContent], {
type: mimeTypes.TEXT_PLAIN
}));
} else {
restoreOldHtmlContent();
}
}
uploadFunction();
}, 0);
};
if (contentCount > 0) {
uploadFunction();
// do not trigger any other actions
return false;
}
uploadContentFunction();
// trigger other actions to catch content
return true;
}
/**
* Safari creates two files when pasting an image from clipboard, one PNG and one JPEG.
* If that happens, remove the JPEG and only keep the PNG.
*/
protected _cleanupFiles(files: BlobWithName[]) {
if (files.length !== 2) {
return;
}
let pngImage: BlobWithName;
let jpgImage: BlobWithName;
files.forEach(file => {
// Check for the scoutName because it will only be set if it is likely a paste from clipboard event
if (file.name === 'image.png' && file.scoutName) {
pngImage = file;
} else if (file.name === 'image.jpeg' && file.scoutName) {
jpgImage = file;
}
});
if (pngImage && jpgImage) {
arrays.remove(files, jpgImage);
}
}
}