snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { LocaleKey } from '../../locale/types';
|
||||
import { createIdentifier, Disposable, ILogService, Inject, LocaleService, Optional } from '@univerjs/core';
|
||||
import { sanitizeParsedHtml } from '../../utils/html';
|
||||
import { INotificationService } from '../notification/notification.service';
|
||||
import { supportClipboardAPI } from './clipboard-utils';
|
||||
|
||||
export const PLAIN_TEXT_CLIPBOARD_MIME_TYPE = 'text/plain';
|
||||
export const HTML_CLIPBOARD_MIME_TYPE = 'text/html';
|
||||
export const FILE_PNG_CLIPBOARD_MIME_TYPE = 'image/png';
|
||||
export const FILE__JPEG_CLIPBOARD_MIME_TYPE = 'image/jpeg';
|
||||
export const FILE__BMP_CLIPBOARD_MIME_TYPE = 'image/bmp';
|
||||
export const FILE__WEBP_CLIPBOARD_MIME_TYPE = 'image/webp';
|
||||
export const FILE_SVG_XML_CLIPBOARD_MIME_TYPE = 'image/svg+xml';
|
||||
|
||||
export const imageMimeTypeSet = new Set([
|
||||
FILE__BMP_CLIPBOARD_MIME_TYPE,
|
||||
FILE__JPEG_CLIPBOARD_MIME_TYPE,
|
||||
FILE__WEBP_CLIPBOARD_MIME_TYPE,
|
||||
FILE_PNG_CLIPBOARD_MIME_TYPE,
|
||||
]);
|
||||
|
||||
const DROP_CONTENT_TAGS = new Set([
|
||||
'base',
|
||||
'button',
|
||||
'embed',
|
||||
'form',
|
||||
'frame',
|
||||
'frameset',
|
||||
'head',
|
||||
'iframe',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'object',
|
||||
'script',
|
||||
'select',
|
||||
'style',
|
||||
'template',
|
||||
'textarea',
|
||||
]);
|
||||
|
||||
const ALLOWED_HTML_TAGS = new Set([
|
||||
'a',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'caption',
|
||||
'code',
|
||||
'col',
|
||||
'colgroup',
|
||||
'dd',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'em',
|
||||
'font',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'img',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
's',
|
||||
'span',
|
||||
'strike',
|
||||
'strong',
|
||||
'sub',
|
||||
'sup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
'u',
|
||||
'ul',
|
||||
]);
|
||||
|
||||
const ALLOWED_HTML_ATTRIBUTES = new Set([
|
||||
'align',
|
||||
'alt',
|
||||
'aria-colindex',
|
||||
'aria-colspan',
|
||||
'aria-label',
|
||||
'aria-labelledby',
|
||||
'aria-rowindex',
|
||||
'aria-rowspan',
|
||||
'bgcolor',
|
||||
'border',
|
||||
'cellpadding',
|
||||
'cellspacing',
|
||||
'class',
|
||||
'colspan',
|
||||
'color',
|
||||
'dir',
|
||||
'face',
|
||||
'headers',
|
||||
'height',
|
||||
'href',
|
||||
'lang',
|
||||
'rel',
|
||||
'role',
|
||||
'rowspan',
|
||||
'size',
|
||||
'src',
|
||||
'style',
|
||||
'target',
|
||||
'title',
|
||||
'valign',
|
||||
'width',
|
||||
]);
|
||||
|
||||
/**
|
||||
* This interface provides an interface to access system's clipboard.
|
||||
*/
|
||||
export interface IClipboardInterfaceService {
|
||||
/**
|
||||
* Write plain text into clipboard. Use write() to write both plain text and html.
|
||||
* @param text
|
||||
*/
|
||||
writeText(text: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Write both plain text and html into clipboard.
|
||||
* @param text
|
||||
* @param html
|
||||
*/
|
||||
write(text: string, html: string, customData?: Record<string, string>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Read plain text from clipboard. Use read() to read both plain text and html.
|
||||
* @returns plain text
|
||||
*/
|
||||
readText(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Read `ClipboardItem[]` from clipboard.
|
||||
*/
|
||||
read(): Promise<ClipboardItem[]>;
|
||||
|
||||
/**
|
||||
* This property tells if the platform supports reading data directly from the clipboard.
|
||||
*/
|
||||
readonly supportClipboard: boolean;
|
||||
}
|
||||
|
||||
export const IClipboardInterfaceService = createIdentifier<IClipboardInterfaceService>(
|
||||
'univer.clipboard-interface-service'
|
||||
);
|
||||
|
||||
export class BrowserClipboardService extends Disposable implements IClipboardInterfaceService {
|
||||
get supportClipboard(): boolean {
|
||||
return supportClipboardAPI();
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(LocaleService) private readonly _localeService: LocaleService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@Optional(INotificationService) private readonly _notificationService?: INotificationService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async write(text: string, html: string, customData?: Record<string, string>): Promise<void> {
|
||||
if (!this.supportClipboard || typeof ClipboardItem === 'undefined' || typeof navigator.clipboard.write !== 'function') {
|
||||
return this._legacyCopyHtml(text, html);
|
||||
}
|
||||
|
||||
try {
|
||||
// write both pure text content and html content to the clipboard
|
||||
return await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[PLAIN_TEXT_CLIPBOARD_MIME_TYPE]: new Blob([text], { type: PLAIN_TEXT_CLIPBOARD_MIME_TYPE }),
|
||||
[HTML_CLIPBOARD_MIME_TYPE]: new Blob([html], { type: HTML_CLIPBOARD_MIME_TYPE }),
|
||||
...Object.fromEntries(Object.entries(customData ?? {}).map(([type, value]) => [type, new Blob([value], { type })])),
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (customData && Object.keys(customData).length) {
|
||||
try {
|
||||
return await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[PLAIN_TEXT_CLIPBOARD_MIME_TYPE]: new Blob([text], { type: PLAIN_TEXT_CLIPBOARD_MIME_TYPE }),
|
||||
[HTML_CLIPBOARD_MIME_TYPE]: new Blob([html], { type: HTML_CLIPBOARD_MIME_TYPE }),
|
||||
}),
|
||||
]);
|
||||
} catch (fallbackError) {
|
||||
this._logService.error('[BrowserClipboardService]', fallbackError);
|
||||
this._showClipboardAuthenticationNotification();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._logService.error('[BrowserClipboardService]', error);
|
||||
this._showClipboardAuthenticationNotification();
|
||||
}
|
||||
}
|
||||
|
||||
async writeText(text: string): Promise<void> {
|
||||
if (!this.supportClipboard) {
|
||||
return this._legacyCopyText(text);
|
||||
}
|
||||
|
||||
// use new Clipboard API first
|
||||
try {
|
||||
return await navigator.clipboard.writeText(text);
|
||||
} catch (error) {
|
||||
this._logService.error('[BrowserClipboardService]', error);
|
||||
this._showClipboardAuthenticationNotification();
|
||||
}
|
||||
}
|
||||
|
||||
async read(): Promise<ClipboardItem[]> {
|
||||
if (!this.supportClipboard) {
|
||||
throw new Error('[BrowserClipboardService] read() is not supported on this platform.');
|
||||
}
|
||||
|
||||
try {
|
||||
return navigator.clipboard.read();
|
||||
} catch (e) {
|
||||
this._logService.error('[BrowserClipboardService]', e);
|
||||
this._showClipboardAuthenticationNotification();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async readText(): Promise<string> {
|
||||
if (!this.supportClipboard) {
|
||||
throw new Error('[BrowserClipboardService] read() is not supported on this platform.');
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.clipboard.readText();
|
||||
} catch (e) {
|
||||
this._logService.error('[BrowserClipboardService]', e);
|
||||
this._showClipboardAuthenticationNotification();
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private _legacyCopyHtml(text: string, html: string): void {
|
||||
const activeElement = document.activeElement;
|
||||
const sanitizedHtml = serializeSanitizedHtmlForClipboard(html);
|
||||
let handledByClipboardEvent = false;
|
||||
|
||||
const onCopy = (event: ClipboardEvent) => {
|
||||
if (!event.clipboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData(PLAIN_TEXT_CLIPBOARD_MIME_TYPE, text);
|
||||
event.clipboardData.setData(HTML_CLIPBOARD_MIME_TYPE, sanitizedHtml);
|
||||
handledByClipboardEvent = true;
|
||||
};
|
||||
|
||||
document.addEventListener('copy', onCopy);
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
|
||||
if (!handledByClipboardEvent) {
|
||||
const container = createCopyHtmlContainer();
|
||||
document.body.appendChild(container);
|
||||
container.innerHTML = sanitizedHtml;
|
||||
|
||||
try {
|
||||
select(container);
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
document.removeEventListener('copy', onCopy);
|
||||
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
activeElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _legacyCopyText(text: string): void {
|
||||
const activeElement = document.activeElement;
|
||||
const container = createCopyTextContainer();
|
||||
document.body.appendChild(container);
|
||||
container.value = text;
|
||||
|
||||
try {
|
||||
select(container);
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
// reset previous elements focus state
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
activeElement.focus();
|
||||
}
|
||||
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
private _showClipboardAuthenticationNotification(): void {
|
||||
this._notificationService?.show({
|
||||
type: 'warning',
|
||||
title: this._localeService.t<LocaleKey>('ui.clipboard.authentication.title'),
|
||||
content: this._localeService.t<LocaleKey>('ui.clipboard.authentication.content'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createCopyTextContainer() {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.style.position = 'absolute';
|
||||
textArea.style.height = '1px';
|
||||
textArea.style.width = '1px';
|
||||
textArea.style.opacity = '0';
|
||||
textArea.readOnly = true;
|
||||
return textArea;
|
||||
}
|
||||
|
||||
function createCopyHtmlContainer() {
|
||||
const div = document.createElement('div');
|
||||
div.contentEditable = 'true';
|
||||
div.style.position = 'absolute';
|
||||
div.style.opacity = '0';
|
||||
div.style.height = '1px';
|
||||
div.style.width = '1px';
|
||||
return div;
|
||||
}
|
||||
|
||||
function sanitizeHtmlForClipboard(html: string): DocumentFragment {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, HTML_CLIPBOARD_MIME_TYPE);
|
||||
sanitizeParsedHtml(doc, {
|
||||
strippedSelector: 'script, iframe, object, embed',
|
||||
});
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
Array.from(doc.body.childNodes).forEach((node) => {
|
||||
const sanitizedNode = sanitizeHtmlNode(node);
|
||||
|
||||
if (sanitizedNode) {
|
||||
fragment.appendChild(sanitizedNode);
|
||||
}
|
||||
});
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function serializeSanitizedHtmlForClipboard(html: string): string {
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(sanitizeHtmlForClipboard(html));
|
||||
const sanitizedHtml = container.innerHTML;
|
||||
const isUniverExcelHtml = html.includes('xmlns:x="urn:schemas-microsoft-com:office:excel"')
|
||||
&& html.includes('<meta name="ProgId" content="Excel.Sheet">')
|
||||
&& html.includes('<meta name="Generator" content="Univer">');
|
||||
|
||||
if (!isUniverExcelHtml) {
|
||||
return sanitizedHtml;
|
||||
}
|
||||
|
||||
return `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="ProgId" content="Excel.Sheet"><meta name="Generator" content="Univer"></head><body><!--StartFragment-->${sanitizedHtml}<!--EndFragment--></body></html>`;
|
||||
}
|
||||
|
||||
function sanitizeHtmlNode(node: Node): Node | null {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return document.createTextNode(node.textContent ?? '');
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceElement = node as HTMLElement;
|
||||
const tagName = sourceElement.tagName.toLowerCase();
|
||||
|
||||
if (DROP_CONTENT_TAGS.has(tagName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ALLOWED_HTML_TAGS.has(tagName)) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
Array.from(sourceElement.childNodes).forEach((childNode) => {
|
||||
const sanitizedChildNode = sanitizeHtmlNode(childNode);
|
||||
|
||||
if (sanitizedChildNode) {
|
||||
fragment.appendChild(sanitizedChildNode);
|
||||
}
|
||||
});
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
const sanitizedElement = document.createElement(tagName);
|
||||
copySanitizedAttributes(sourceElement, sanitizedElement);
|
||||
|
||||
Array.from(sourceElement.childNodes).forEach((childNode) => {
|
||||
const sanitizedChildNode = sanitizeHtmlNode(childNode);
|
||||
|
||||
if (sanitizedChildNode) {
|
||||
sanitizedElement.appendChild(sanitizedChildNode);
|
||||
}
|
||||
});
|
||||
|
||||
return sanitizedElement;
|
||||
}
|
||||
|
||||
function copySanitizedAttributes(sourceElement: HTMLElement, targetElement: HTMLElement) {
|
||||
Array.from(sourceElement.attributes).forEach((attribute) => {
|
||||
const attributeName = attribute.name.toLowerCase();
|
||||
|
||||
if (attributeName.startsWith('on')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attributeName.startsWith('data-') || attributeName.startsWith('aria-')) {
|
||||
targetElement.setAttribute(attribute.name, attribute.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_HTML_ATTRIBUTES.has(attributeName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attributeName === 'href' || attributeName === 'src') {
|
||||
const sanitizedUrl = sanitizeHtmlUrl(attribute.value, attributeName === 'src');
|
||||
|
||||
if (sanitizedUrl) {
|
||||
targetElement.setAttribute(attribute.name, sanitizedUrl);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (attributeName === 'style') {
|
||||
const sanitizedStyle = sanitizeInlineStyle(attribute.value);
|
||||
|
||||
if (sanitizedStyle) {
|
||||
targetElement.setAttribute(attribute.name, sanitizedStyle);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement.setAttribute(attribute.name, attribute.value);
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeHtmlUrl(value: string, allowDataImage: boolean): string | null {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmedValue.startsWith('#')) {
|
||||
return trimmedValue;
|
||||
}
|
||||
|
||||
if (/^(?:https?:|mailto:|tel:)/i.test(trimmedValue)) {
|
||||
return trimmedValue;
|
||||
}
|
||||
|
||||
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmedValue)) {
|
||||
return trimmedValue;
|
||||
}
|
||||
|
||||
if (/^(?:\/|\.\/|\.\.\/)/.test(trimmedValue)) {
|
||||
return trimmedValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeInlineStyle(styleText: string): string {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = styleText;
|
||||
|
||||
const sanitizedDeclarations: string[] = [];
|
||||
|
||||
Array.from(probe.style).forEach((propertyName) => {
|
||||
const propertyValue = probe.style.getPropertyValue(propertyName).trim();
|
||||
|
||||
if (!propertyValue || isUnsafeStyleValue(propertyValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sanitizedDeclarations.push(`${propertyName}: ${propertyValue}`);
|
||||
});
|
||||
|
||||
return sanitizedDeclarations.join('; ');
|
||||
}
|
||||
|
||||
function isUnsafeStyleValue(styleValue: string): boolean {
|
||||
return /(expression\s*\(|url\s*\(|javascript:|vbscript:|mocha:|-moz-binding|behavior\s*:|@import)/i.test(styleValue);
|
||||
}
|
||||
|
||||
function select(element: HTMLTextAreaElement | HTMLDivElement): string {
|
||||
if (element instanceof HTMLTextAreaElement) {
|
||||
const isReadOnly = element.hasAttribute('readonly');
|
||||
|
||||
if (!isReadOnly) {
|
||||
element.setAttribute('readonly', '');
|
||||
}
|
||||
|
||||
element.select();
|
||||
element.setSelectionRange(0, element.value.length);
|
||||
|
||||
if (!isReadOnly) {
|
||||
element.removeAttribute('readonly');
|
||||
}
|
||||
|
||||
return element.value;
|
||||
}
|
||||
|
||||
if (element.hasAttribute('contenteditable')) {
|
||||
element.focus();
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
return selection.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user