Add embedable images (#564)

* Add embedable bitmaps

* Initial work on blob urls

* Finish implementing data url

* Fix some bugs

* Rename bitmap to image

* Fix loading image on document load

* Add transform properties for image

* Remove some logging

* Add image dimensions

* Implement system copy and paste

* Fix pasting images

* Fix test

* Address code review

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube 2022-03-27 11:43:41 +01:00 committed by Keavon Chambers
parent 0ee492a857
commit 51c31f042b
23 changed files with 462 additions and 59 deletions

View file

@ -75,9 +75,23 @@
<CanvasRuler :origin="rulerOrigin.y" :majorMarkSpacing="rulerSpacing" :numberInterval="rulerInterval" :direction="'Vertical'" ref="rulerVertical" />
</LayoutCol>
<LayoutCol class="canvas-area">
<div class="canvas" data-canvas ref="canvas" :style="{ cursor: canvasCursor }" @pointerdown="(e: PointerEvent) => canvasPointerDown(e)">
<div
class="canvas"
data-canvas
ref="canvas"
:style="{ cursor: canvasCursor }"
@pointerdown="(e: PointerEvent) => canvasPointerDown(e)"
@dragover="(e) => e.preventDefault()"
@drop="(e) => pasteFile(e)"
>
<svg class="artboards" v-html="artboardSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
<svg class="artwork" v-html="artworkSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
<svg
class="artwork"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
v-html="artworkSvg"
:style="{ width: canvasSvgWidth, height: canvasSvgHeight }"
></svg>
<svg class="overlays" v-html="overlaysSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
</div>
</LayoutCol>
@ -267,7 +281,9 @@ import {
UpdateToolOptionsLayout,
defaultWidgetLayout,
UpdateDocumentBarLayout,
UpdateImageData,
TriggerTextCommit,
TriggerTextCopy,
TriggerViewportResize,
DisplayRemoveEditableTextbox,
DisplayEditableTextbox,
@ -312,6 +328,22 @@ export default defineComponent({
if (rulerHorizontal) rulerHorizontal.handleResize();
if (rulerVertical) rulerVertical.handleResize();
},
pasteFile(e: DragEvent) {
const { dataTransfer } = e;
if (!dataTransfer) return;
e.preventDefault();
Array.from(dataTransfer.items).forEach((item) => {
const file = item.getAsFile();
if (file && file.type.startsWith("image")) {
file.arrayBuffer().then((buffer): void => {
const u8Array = new Uint8Array(buffer);
this.editor.instance.paste_image(file.type, u8Array, e.clientX, e.clientY);
});
}
});
},
translateCanvasX(newValue: number) {
const delta = newValue - this.scrollbarPos.x;
this.scrollbarPos.x = newValue;
@ -421,6 +453,15 @@ export default defineComponent({
this.editor.dispatcher.subscribeJsMessage(TriggerTextCommit, () => {
if (this.textInput) this.editor.instance.on_change_text(textInputCleanup(this.textInput.innerText));
});
this.editor.dispatcher.subscribeJsMessage(TriggerTextCopy, async (triggerTextCopy) => {
// Clipboard API supported?
if (!navigator.clipboard) return;
// copy text to clipboard
if (navigator.clipboard.writeText) {
await navigator.clipboard.writeText(triggerTextCopy.copy_text);
}
});
this.editor.dispatcher.subscribeJsMessage(DisplayEditableTextbox, (displayEditableTextbox) => {
this.textInput = document.createElement("DIV") as HTMLDivElement;
@ -457,6 +498,19 @@ export default defineComponent({
});
this.editor.dispatcher.subscribeJsMessage(TriggerViewportResize, this.viewportResize);
this.editor.dispatcher.subscribeJsMessage(UpdateImageData, (updateImageData) => {
updateImageData.image_data.forEach((element) => {
// Using updateImageData.image_data.buffer returns undefined for some reason?
const blob = new Blob([new Uint8Array(element.image_data.values()).buffer], { type: element.mime });
const url = URL.createObjectURL(blob);
createImageBitmap(blob).then((image) => {
this.editor.instance.set_image_blob_url(element.path, url, image.width, image.height);
});
});
});
// TODO(mfish33): Replace with initialization system Issue:#524
// Get initial Document Bar
this.editor.instance.init_document_bar();

View file

@ -75,8 +75,8 @@
>
<LayoutRow class="layer-type-icon">
<IconLabel v-if="listing.entry.layer_type === 'Folder'" :icon="'NodeFolder'" title="Folder" />
<IconLabel v-else-if="listing.entry.layer_type === 'Image'" :icon="'NodeImage'" title="Path" />
<IconLabel v-else-if="listing.entry.layer_type === 'Shape'" :icon="'NodeShape'" title="Path" />
<IconLabel v-else-if="listing.entry.layer_type === 'Image'" :icon="'NodeImage'" title="Image" />
<IconLabel v-else-if="listing.entry.layer_type === 'Shape'" :icon="'NodeShape'" title="Shape" />
<IconLabel v-else-if="listing.entry.layer_type === 'Text'" :icon="'NodeText'" title="Path" />
</LayoutRow>
<LayoutRow class="layer-name" @dblclick="() => onEditLayerName(listing)">

View file

@ -119,7 +119,8 @@ function makeMenuEntries(editor: EditorState): MenuListEntries {
[
{ label: "Cut", shortcut: ["KeyControl", "KeyX"], action: async (): Promise<void> => editor.instance.cut() },
{ label: "Copy", icon: "Copy", shortcut: ["KeyControl", "KeyC"], action: async (): Promise<void> => editor.instance.copy() },
{ label: "Paste", icon: "Paste", shortcut: ["KeyControl", "KeyV"], action: async (): Promise<void> => editor.instance.paste() },
// TODO: Fix this
// { label: "Paste", icon: "Paste", shortcut: ["KeyControl", "KeyV"], action: async (): Promise<void> => editor.instance.paste() },
],
],
},

View file

@ -308,6 +308,10 @@ export class DisplayEditableTextbox extends JsMessage {
readonly color!: Color;
}
export class UpdateImageData extends JsMessage {
readonly image_data!: ImageData[];
}
export class DisplayRemoveEditableTextbox extends JsMessage {}
export class UpdateDocumentLayer extends JsMessage {
@ -371,6 +375,14 @@ export class LayerMetadata {
export type LayerType = "Folder" | "Image" | "Shape" | "Text";
export class ImageData {
readonly path!: BigUint64Array;
readonly mime!: string;
readonly image_data!: Uint8Array;
}
export class IndexedDbDocumentDetails extends DocumentDetails {
@Transform(({ value }: { value: BigInt }) => value.toString())
id!: string;
@ -488,6 +500,10 @@ export class DisplayDialogComingSoon extends JsMessage {
export class TriggerTextCommit extends JsMessage {}
export class TriggerTextCopy extends JsMessage {
readonly copy_text!: string;
}
export class TriggerViewportResize extends JsMessage {}
// Any is used since the type of the object should be known from the rust side
@ -504,12 +520,14 @@ export const messageConstructors: Record<string, MessageMaker> = {
DisplayDialogPanic,
DisplayDocumentLayerTreeStructure: newDisplayDocumentLayerTreeStructure,
DisplayEditableTextbox,
UpdateImageData,
DisplayRemoveEditableTextbox,
TriggerFileDownload,
TriggerFileUpload,
TriggerIndexedDbRemoveDocument,
TriggerIndexedDbWriteDocument,
TriggerTextCommit,
TriggerTextCopy,
TriggerViewportResize,
UpdateActiveDocument,
UpdateActiveTool,

View file

@ -26,6 +26,7 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
{ target: window, eventName: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) },
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onMouseScroll(e), options: { passive: false } },
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent): void => onModifyInputField(e) },
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent): void => onPaste(e) },
];
let viewportPointerInteractionOngoing = false;
@ -45,6 +46,9 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
if (key !== "escape" && !(key === "enter" && e.ctrlKey) && target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable))
return false;
// Don't redirect paste
if (key === "v" && e.ctrlKey) return false;
// Don't redirect a fullscreen request
if (key === "f11" && e.type === "keydown" && !e.repeat) {
e.preventDefault();
@ -208,6 +212,31 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
}
};
const onPaste = (e: ClipboardEvent): void => {
const dataTransfer = e.clipboardData;
if (!dataTransfer) return;
e.preventDefault();
Array.from(dataTransfer.items).forEach((item) => {
if (item.type === "text/plain") {
item.getAsString((text) => {
if (text.startsWith("graphite/layer: ")) {
editor.instance.paste_serialized_data(text.substring(16, text.length));
}
});
}
const file = item.getAsFile();
if (file && file.type.startsWith("image")) {
file.arrayBuffer().then((buffer): void => {
const u8Array = new Uint8Array(buffer);
editor.instance.paste_image(file.type, u8Array, undefined, undefined);
});
}
});
};
// Event bindings
const addListeners = (): void => {