Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] Clipboard: support images in the clipboard #5098

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions demo/file_store.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,9 @@ export class FileStore {
async delete(path) {
console.warn("cannot delete file. Not implemented");
}

async getFile(path) {
const response = await fetch(path);
return await response.blob();
}
}
4 changes: 2 additions & 2 deletions src/actions/edit_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const copy: ActionSpec = {
isReadonlyAllowed: true,
execute: async (env) => {
env.model.dispatch("COPY");
await env.clipboard.write(env.model.getters.getClipboardContent());
await env.clipboard.write(await env.model.getters.getOsClipboardContentAsync());
},
icon: "o-spreadsheet-Icon.CLIPBOARD",
};
Expand All @@ -39,7 +39,7 @@ export const cut: ActionSpec = {
description: "Ctrl+X",
execute: async (env) => {
interactiveCut(env);
await env.clipboard.write(env.model.getters.getClipboardContent());
await env.clipboard.write(await env.model.getters.getOsClipboardContentAsync());
},
icon: "o-spreadsheet-Icon.CUT",
};
Expand Down
7 changes: 4 additions & 3 deletions src/actions/menu_items_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,13 @@ async function paste(env: SpreadsheetChildEnv, pasteOption?: ClipboardPasteOptio
const osClipboard = await env.clipboard.read();
switch (osClipboard.status) {
case "ok":
const clipboardContent = parseOSClipboardContent(osClipboard.content);
const clipboardId = clipboardContent.data?.clipboardId;
const clipboardId = env.model.getters.getClipboardId();
const clipboardContent = await parseOSClipboardContent(env, osClipboard.content, clipboardId);
const contentClipboardId = clipboardContent.data?.clipboardId;

const target = env.model.getters.getSelectedZones();

if (env.model.getters.getClipboardId() !== clipboardId) {
if (clipboardId !== contentClipboardId) {
interactivePasteFromOS(env, target, clipboardContent, pasteOption);
} else {
interactivePaste(env, target, pasteOption);
Expand Down
27 changes: 18 additions & 9 deletions src/components/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { Store, useStore } from "../../store_engine";
import { DOMFocusableElementStore } from "../../stores/DOM_focus_store";
import { ArrayFormulaHighlight } from "../../stores/array_formula_highlight";
import { HighlightStore } from "../../stores/highlight_store";
import { AllowedImageMimeTypes } from "../../types/image";
import {
Align,
CellValueType,
Expand Down Expand Up @@ -610,11 +611,8 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
} else {
this.env.model.dispatch("COPY");
}
const content = this.env.model.getters.getClipboardContent();
const clipboardData = ev.clipboardData;
for (const type in content) {
clipboardData?.setData(type, content[type]);
}
const osContent = await this.env.model.getters.getOsClipboardContentAsync();
await this.env.clipboard.write(osContent);
ev.preventDefault();
}

Expand All @@ -629,20 +627,31 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
if (!clipboardData) {
return;
}

const image = [...clipboardData?.files]?.find((file) =>
AllowedImageMimeTypes.includes(file.type as (typeof AllowedImageMimeTypes)[number])
);
const osClipboard = {
content: {
[ClipboardMIMEType.PlainText]: clipboardData?.getData(ClipboardMIMEType.PlainText),
[ClipboardMIMEType.Html]: clipboardData?.getData(ClipboardMIMEType.Html),
},
};
if (image) {
// TODO: support import of multiple images
osClipboard.content[image.type] = image;
}

const target = this.env.model.getters.getSelectedZones();
const isCutOperation = this.env.model.getters.isCutOperation();

const clipboardContent = parseOSClipboardContent(osClipboard.content);
const clipboardId = clipboardContent.data?.clipboardId;
if (this.env.model.getters.getClipboardId() === clipboardId) {
const clipboardId = this.env.model.getters.getClipboardId();
const clipboardContent = await parseOSClipboardContent(
this.env,
osClipboard.content,
clipboardId
);
const contentClipboardId = clipboardContent.data?.clipboardId;
if (clipboardId === contentClipboardId) {
interactivePaste(this.env, target);
} else {
interactivePasteFromOS(this.env, target, clipboardContent);
Expand Down
51 changes: 37 additions & 14 deletions src/helpers/clipboard/clipboard_helpers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { SpreadsheetClipboardData } from "../../plugins/ui_stateful";
import {
ClipboardCellData,
ClipboardMIMEType,
OSClipboardContent,
ParsedOSClipboardContent,
SpreadsheetChildEnv,
UID,
Zone,
} from "../../types";
import { AllowedImageMimeTypes } from "../../types/image";
import { mergeOverlappingZones, positions } from "../zones";

export function getClipboardDataPositions(sheetId: UID, zones: Zone[]): ClipboardCellData {
Expand Down Expand Up @@ -62,22 +65,42 @@ export function getPasteZones<T>(target: Zone[], content: T[][]): Zone[] {
return target.map((t) => splitZoneForPaste(t, width, height)).flat();
}

export function parseOSClipboardContent(content: OSClipboardContent): ParsedOSClipboardContent {
if (!content[ClipboardMIMEType.Html]) {
export async function parseOSClipboardContent(
env: SpreadsheetChildEnv,
content: OSClipboardContent,
clipboardId: string
): Promise<ParsedOSClipboardContent> {
let contentClipboardId: string | undefined;
let spreadsheetContent: SpreadsheetClipboardData | undefined = undefined;
if (content[ClipboardMIMEType.Html]) {
const htmlDocument = new DOMParser().parseFromString(
content[ClipboardMIMEType.Html],
"text/html"
);
const oSheetClipboardData = htmlDocument
.querySelector("div")
?.getAttribute("data-osheet-clipboard");
spreadsheetContent = oSheetClipboardData && JSON.parse(oSheetClipboardData);
contentClipboardId = spreadsheetContent?.clipboardId;
}
if (contentClipboardId !== clipboardId) {
const clipboardContent: ParsedOSClipboardContent = {
text: content[ClipboardMIMEType.PlainText],
data: spreadsheetContent,
};
for (const type of AllowedImageMimeTypes) {
if (content[type]) {
// TODO: support multiple import
const imageData = await env.imageProvider?.upload(content[type]!);
clipboardContent.imageData = imageData;
break;
}
}
return clipboardContent;
} else {
return {
text: content[ClipboardMIMEType.PlainText],
data: spreadsheetContent,
};
}
const htmlDocument = new DOMParser().parseFromString(
content[ClipboardMIMEType.Html],
"text/html"
);
const oSheetClipboardData = htmlDocument
.querySelector("div")
?.getAttribute("data-osheet-clipboard");
const spreadsheetContent = oSheetClipboardData && JSON.parse(oSheetClipboardData);
return {
text: content[ClipboardMIMEType.PlainText],
data: spreadsheetContent,
};
}
22 changes: 16 additions & 6 deletions src/helpers/clipboard/navigator_clipboard_wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AllowedImageMimeTypes } from "../../types/image";
import { ClipboardMIMEType, OSClipboardContent } from "./../../types/clipboard";

export type ClipboardReadResult =
Expand Down Expand Up @@ -64,7 +65,12 @@ class WebClipboardWrapper implements ClipboardInterface {
for (const item of clipboardItems) {
for (const type of item.types) {
const blob = await item.getType(type);
clipboardContent[type as ClipboardMIMEType] = await blob.text();
if (type in AllowedImageMimeTypes) {
clipboardContent[type] = blob;
} else {
const text = await blob.text();
clipboardContent[type] = text;
}
}
}
return { status: "ok", content: clipboardContent };
Expand All @@ -83,14 +89,18 @@ class WebClipboardWrapper implements ClipboardInterface {
}

private getClipboardItems(content: OSClipboardContent): ClipboardItems {
const clipboardItemData = {
[ClipboardMIMEType.PlainText]: this.getBlob(content, ClipboardMIMEType.PlainText),
[ClipboardMIMEType.Html]: this.getBlob(content, ClipboardMIMEType.Html),
};
const clipboardItemData = {};
for (const type of Object.keys(content)) {
clipboardItemData[type] = this.getBlob(content, type);
}
return [new ClipboardItem(clipboardItemData)];
}

private getBlob(clipboardContent: OSClipboardContent, type: ClipboardMIMEType): Blob {
private getBlob(clipboardContent: OSClipboardContent, type: string): Blob {
const content = clipboardContent[type];
if (content instanceof Blob) {
return content;
}
return new Blob([clipboardContent[type] || ""], {
type,
});
Expand Down
53 changes: 53 additions & 0 deletions src/helpers/figures/charts/chart_ui_common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,59 @@ export function chartToImage(
return undefined;
}

export async function chartToImageBlob(
runtime: ChartRuntime,
figure: Figure,
type: string
): Promise<Blob | null> {
// wrap the canvas in a div with a fixed size because chart.js would
// fill the whole page otherwise
const div = document.createElement("div");
div.style.width = `${figure.width}px`;
div.style.height = `${figure.height}px`;
const canvas = document.createElement("canvas");
div.append(canvas);
canvas.setAttribute("width", figure.width.toString());
canvas.setAttribute("height", figure.height.toString());
let finalContent: Blob | null = null;
// we have to add the canvas to the DOM otherwise it won't be rendered
document.body.append(div);
if ("chartJsConfig" in runtime) {
const config = deepCopy(runtime.chartJsConfig);
config.plugins = [backgroundColorChartJSPlugin];
const chart = new window.Chart(canvas, config);
const imgContent = chart.toBase64Image() as string;
finalContent = base64ToBlob(imgContent, "image/png");
chart.destroy();
div.remove();
} else if (type === "scorecard") {
const design = getScorecardConfiguration(figure, runtime as ScorecardChartRuntime);
drawScoreChart(design, canvas);
finalContent = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
div.remove();
} else if (type === "gauge") {
drawGaugeChart(canvas, runtime as GaugeChartRuntime);
finalContent = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
div.remove();
}
return finalContent;
}

function base64ToBlob(base64: string, mimeType: string): Blob {
// Remove the data URL part if present
const byteCharacters = atob(base64.split(",")[1]);

const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}

const byteArray = new Uint8Array(byteNumbers);

// Create a Blob object from the byteArray
return new Blob([byteArray], { type: mimeType });
}

/**
* Custom chart.js plugin to set the background color of the canvas
* https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
Expand Down
8 changes: 7 additions & 1 deletion src/helpers/figures/images/image_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export class ImageProvider implements ImageProviderInterface {
return { path, size, mimetype: file.type };
}

async upload(file: File): Promise<Image> {
const path = await this.fileStore.upload(file);
const size = await this.getImageOriginalSize(path);
return { path, size, mimetype: file.type };
}

private getImageFromUser(): Promise<File> {
return new Promise((resolve, reject) => {
const input = document.createElement("input");
Expand All @@ -34,7 +40,7 @@ export class ImageProvider implements ImageProviderInterface {

getImageOriginalSize(path: string): Promise<FigureSize> {
return new Promise((resolve, reject) => {
const image = new Image();
const image = new window.Image();
image.src = path;
image.addEventListener("load", () => {
const size = { width: image.width, height: image.height };
Expand Down
1 change: 1 addition & 0 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ export class Model extends EventBus<any> implements CommandDispatcher {
session: this.session,
defaultCurrency: this.config.defaultCurrency,
customColors: this.config.customColors || [],
external: this.config.external,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/plugins/ui_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface UIPluginConfig {
readonly session: Session;
readonly defaultCurrency?: Partial<Currency>;
readonly customColors: Color[];
readonly external: ModelConfig["external"];
}

export interface UIPluginConstructor {
Expand Down
Loading