mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-22 04:28:11 +08:00
Improve UX of importing vs. opening files (#3661)
* wip * fix drag and drop * fix * fix tests * fix tests * fix warning * Partial code review * add dialog * fix web * fix web * push back release candidate expiry * Code review * Reduce code duplication for pasting files in frontend --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
@@ -19,8 +19,9 @@
|
||||
} from "@graphite/messages";
|
||||
import type { AppWindowState } from "@graphite/state-providers/app-window";
|
||||
import type { DocumentState } from "@graphite/state-providers/document";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { textInputCleanup } from "@graphite/utility-functions/keyboard-entry";
|
||||
import { extractPixelData, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
|
||||
import { rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
|
||||
import { setupViewportResizeObserver, cleanupViewportResizeObserver } from "@graphite/utility-functions/viewports";
|
||||
|
||||
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@graphite/components/floating-menus/EyedropperPreview.svelte";
|
||||
@@ -130,36 +131,14 @@
|
||||
})($document.toolShelfLayout[0]);
|
||||
|
||||
function dropFile(e: DragEvent) {
|
||||
const { dataTransfer } = e;
|
||||
const [x, y] = e.target instanceof Element && e.target.closest("[data-viewport]") ? [e.clientX, e.clientY] : [undefined, undefined];
|
||||
if (!dataTransfer) return;
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
let mouse: [number, number] | undefined = undefined;
|
||||
if (e.target instanceof Element && e.target.closest("[data-viewport]")) mouse = [e.clientX, e.clientY];
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach(async (item) => {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
if (file.type.includes("svg")) {
|
||||
const svgData = await file.text();
|
||||
editor.handle.pasteSvg(file.name, svgData, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("image")) {
|
||||
const imageData = await extractPixelData(file);
|
||||
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
const graphiteFileSuffix = "." + editor.handle.fileExtension();
|
||||
if (file.name.endsWith(graphiteFileSuffix)) {
|
||||
const content = await file.text();
|
||||
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
|
||||
editor.handle.openDocumentFile(documentName, content);
|
||||
return;
|
||||
}
|
||||
});
|
||||
Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor, mouse));
|
||||
}
|
||||
|
||||
function panCanvasX(newValue: number) {
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
import type { DataBuffer, LayerPanelEntry, Layout } from "@graphite/messages";
|
||||
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
|
||||
import type { TooltipState } from "@graphite/state-providers/tooltip";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { operatingSystem } from "@graphite/utility-functions/platform";
|
||||
import { extractPixelData } from "@graphite/utility-functions/rasterization";
|
||||
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
@@ -508,31 +508,7 @@
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(e.dataTransfer.items).forEach(async (item) => {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
if (file.type.includes("svg")) {
|
||||
const svgData = await file.text();
|
||||
editor.handle.pasteSvg(file.name, svgData, undefined, undefined, insertParentId, insertIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("image")) {
|
||||
const imageData = await extractPixelData(file);
|
||||
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, undefined, undefined, insertParentId, insertIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// When we eventually have sub-documents, this should be changed to import the document instead of opening it in a separate tab
|
||||
const graphiteFileSuffix = "." + editor.handle.fileExtension();
|
||||
if (file.name.endsWith(graphiteFileSuffix)) {
|
||||
const content = await file.text();
|
||||
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
|
||||
editor.handle.openDocumentFile(documentName, content);
|
||||
return;
|
||||
}
|
||||
});
|
||||
Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor, undefined, insertParentId, insertIndex));
|
||||
|
||||
draggingData = undefined;
|
||||
fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import type { Editor } from "@graphite/editor";
|
||||
import type { Layout } from "@graphite/messages";
|
||||
import { patchLayout, UpdateWelcomeScreenButtonsLayout } from "@graphite/messages";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { isDesktop } from "@graphite/utility-functions/platform";
|
||||
import { extractPixelData } from "@graphite/utility-functions/rasterization";
|
||||
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
@@ -33,30 +33,7 @@
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(e.dataTransfer.items).forEach(async (item) => {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
if (file.type.includes("svg")) {
|
||||
const svgData = await file.text();
|
||||
editor.handle.pasteSvg(file.name, svgData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("image")) {
|
||||
const imageData = await extractPixelData(file);
|
||||
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height);
|
||||
return;
|
||||
}
|
||||
|
||||
const graphiteFileSuffix = "." + editor.handle.fileExtension();
|
||||
if (file.name.endsWith(graphiteFileSuffix)) {
|
||||
const content = await file.text();
|
||||
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
|
||||
editor.handle.openDocumentFile(documentName, content);
|
||||
return;
|
||||
}
|
||||
});
|
||||
Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{#if $tooltip.visible}
|
||||
<Tooltip />
|
||||
{/if}
|
||||
{#if isDesktop() && new Date() > new Date("2026-01-31")}
|
||||
{#if isDesktop() && new Date() > new Date("2026-03-15")}
|
||||
<LayoutCol class="release-candidate-expiry">
|
||||
<TextLabel>
|
||||
<p>
|
||||
|
||||
@@ -60,13 +60,13 @@ export function createEditor(): Editor {
|
||||
if (!demoArtwork) return;
|
||||
|
||||
try {
|
||||
const url = new URL(`/demo-artwork/${demoArtwork}.graphite`, document.location.href);
|
||||
const url = new URL(`/demo-artwork/${demoArtwork}.${handle.fileExtension()}`, document.location.href);
|
||||
const data = await fetch(url);
|
||||
if (!data.ok) throw new Error();
|
||||
|
||||
const filename = url.pathname.split("/").pop() || "Untitled";
|
||||
const content = await data.text();
|
||||
handle.openDocumentFile(filename, content);
|
||||
const content = await data.bytes();
|
||||
handle.openFile(`${filename}.${handle.fileExtension()}`, content);
|
||||
|
||||
// Remove the hash fragment from the URL
|
||||
history.replaceState("", "", `${window.location.pathname}${window.location.search}`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type DialogState } from "@graphite/state-providers/dialog";
|
||||
import { type DocumentState } from "@graphite/state-providers/document";
|
||||
import { type FullscreenState } from "@graphite/state-providers/fullscreen";
|
||||
import { type PortfolioState } from "@graphite/state-providers/portfolio";
|
||||
import { pasteFile } from "@graphite/utility-functions/files";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry";
|
||||
import { isDesktop, operatingSystem } from "@graphite/utility-functions/platform";
|
||||
import { extractPixelData } from "@graphite/utility-functions/rasterization";
|
||||
@@ -312,32 +313,8 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach(async (item) => {
|
||||
if (item.type === "text/plain") {
|
||||
item.getAsString((text) => {
|
||||
editor.handle.pasteText(text);
|
||||
});
|
||||
}
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
if (file.type.includes("svg")) {
|
||||
const text = await file.text();
|
||||
editor.handle.pasteSvg(file.name, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("image")) {
|
||||
const imageData = await extractPixelData(file);
|
||||
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height);
|
||||
}
|
||||
|
||||
const graphiteFileSuffix = "." + editor.handle.fileExtension();
|
||||
if (file.name.endsWith(graphiteFileSuffix)) {
|
||||
const content = await file.text();
|
||||
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
|
||||
editor.handle.openDocumentFile(documentName, content);
|
||||
}
|
||||
if (item.type === "text/plain") item.getAsString((text) => editor.handle.pasteText(text));
|
||||
await pasteFile(item, editor);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+13
-13
@@ -745,7 +745,7 @@ export class TriggerFetchAndOpenDocument extends JsMessage {
|
||||
readonly filename!: string;
|
||||
}
|
||||
|
||||
export class TriggerOpenDocument extends JsMessage {}
|
||||
export class TriggerOpen extends JsMessage {}
|
||||
|
||||
export class TriggerImport extends JsMessage {}
|
||||
|
||||
@@ -1666,14 +1666,16 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
DisplayDialogDismiss,
|
||||
DisplayDialogPanic,
|
||||
DisplayEditableTextbox,
|
||||
DisplayEditableTextboxUpdateFontData,
|
||||
DisplayEditableTextboxTransform,
|
||||
DisplayEditableTextboxUpdateFontData,
|
||||
DisplayRemoveEditableTextbox,
|
||||
SendUIMetadata,
|
||||
SendShortcutFullscreen,
|
||||
SendShortcutAltClick,
|
||||
SendShortcutFullscreen,
|
||||
SendShortcutShiftClick,
|
||||
SendUIMetadata,
|
||||
TriggerAboutGraphiteLocalizedCommitDate,
|
||||
TriggerClipboardRead,
|
||||
TriggerClipboardWrite,
|
||||
TriggerDisplayThirdPartyLicensesDialog,
|
||||
TriggerExportImage,
|
||||
TriggerFetchAndOpenDocument,
|
||||
@@ -1683,7 +1685,7 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
TriggerLoadFirstAutoSaveDocument,
|
||||
TriggerLoadPreferences,
|
||||
TriggerLoadRestAutoSaveDocuments,
|
||||
TriggerOpenDocument,
|
||||
TriggerOpen,
|
||||
TriggerOpenLaunchDocuments,
|
||||
TriggerPersistenceRemoveDocument,
|
||||
TriggerPersistenceWriteDocument,
|
||||
@@ -1691,11 +1693,9 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
TriggerSaveDocument,
|
||||
TriggerSaveFile,
|
||||
TriggerSavePreferences,
|
||||
TriggerTextCommit,
|
||||
TriggerClipboardRead,
|
||||
TriggerClipboardWrite,
|
||||
TriggerSelectionRead,
|
||||
TriggerSelectionWrite,
|
||||
TriggerTextCommit,
|
||||
TriggerVisitLink,
|
||||
UpdateActiveDocument,
|
||||
UpdateBox,
|
||||
@@ -1714,6 +1714,7 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
UpdateDocumentScrollbars,
|
||||
UpdateExportReorderIndex,
|
||||
UpdateEyedropperSamplingState,
|
||||
UpdateFullscreen,
|
||||
UpdateGraphFadeArtwork,
|
||||
UpdateGraphViewOverlay,
|
||||
UpdateImportReorderIndex,
|
||||
@@ -1724,6 +1725,7 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
UpdateLayersPanelControlBarRightLayout,
|
||||
UpdateLayersPanelState,
|
||||
UpdateLayerWidths,
|
||||
UpdateMaximized,
|
||||
UpdateMenuBarLayout,
|
||||
UpdateMouseCursor,
|
||||
UpdateNodeGraphControlBarLayout,
|
||||
@@ -1735,22 +1737,20 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
UpdateNodeThumbnail,
|
||||
UpdateOpenDocumentsList,
|
||||
UpdatePlatform,
|
||||
UpdateMaximized,
|
||||
UpdateFullscreen,
|
||||
WindowPointerLockMove,
|
||||
WindowFullscreen,
|
||||
UpdatePropertiesPanelLayout,
|
||||
UpdatePropertiesPanelState,
|
||||
UpdateStatusBarHintsLayout,
|
||||
UpdateStatusBarInfoLayout,
|
||||
UpdateToolOptionsLayout,
|
||||
UpdateToolShelfLayout,
|
||||
UpdateUIScale,
|
||||
UpdateViewportHolePunch,
|
||||
UpdateViewportPhysicalBounds,
|
||||
UpdateUIScale,
|
||||
UpdateVisibleNodes,
|
||||
UpdateWelcomeScreenButtonsLayout,
|
||||
UpdateWirePathInProgress,
|
||||
UpdateWorkingColorsLayout,
|
||||
WindowFullscreen,
|
||||
WindowPointerLockMove,
|
||||
} as const;
|
||||
export type JsMessageType = keyof typeof messageMakers;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TriggerExportImage,
|
||||
TriggerSaveFile,
|
||||
TriggerImport,
|
||||
TriggerOpenDocument,
|
||||
TriggerOpen,
|
||||
UpdateActiveDocument,
|
||||
UpdateOpenDocumentsList,
|
||||
UpdateDataPanelState,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
UpdateLayersPanelState,
|
||||
} from "@graphite/messages";
|
||||
import { downloadFile, downloadFileBlob, upload } from "@graphite/utility-functions/files";
|
||||
import { extractPixelData, rasterizeSVG } from "@graphite/utility-functions/rasterization";
|
||||
import { rasterizeSVG } from "@graphite/utility-functions/rasterization";
|
||||
|
||||
export function createPortfolioState(editor: Editor) {
|
||||
const { subscribe, update } = writable({
|
||||
@@ -45,12 +45,9 @@ export function createPortfolioState(editor: Editor) {
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerFetchAndOpenDocument, async (data) => {
|
||||
try {
|
||||
const { name, filename } = data;
|
||||
const url = new URL(`demo-artwork/${filename}`, document.location.href);
|
||||
const url = new URL(`demo-artwork/${data.filename}`, document.location.href);
|
||||
const response = await fetch(url);
|
||||
const content = await response.text();
|
||||
|
||||
editor.handle.openDocumentFile(name, content);
|
||||
editor.handle.openFile(data.filename, await response.bytes());
|
||||
} catch {
|
||||
// Needs to be delayed until the end of the current call stack so the existing demo artwork dialog can be closed first, otherwise this dialog won't show
|
||||
setTimeout(() => {
|
||||
@@ -58,37 +55,14 @@ export function createPortfolioState(editor: Editor) {
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerOpenDocument, async () => {
|
||||
const suffix = "." + editor.handle.fileExtension();
|
||||
const data = await upload(suffix, "text");
|
||||
|
||||
// Use filename as document name, removing the extension if it exists
|
||||
let documentName = data.filename;
|
||||
if (documentName.endsWith(suffix)) {
|
||||
documentName = documentName.slice(0, -suffix.length);
|
||||
}
|
||||
|
||||
editor.handle.openDocumentFile(documentName, data.content);
|
||||
editor.subscriptions.subscribeJsMessage(TriggerOpen, async () => {
|
||||
const data = await upload(`image/*,.${editor.handle.fileExtension()}`, "data");
|
||||
editor.handle.openFile(data.filename, data.content);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerImport, async () => {
|
||||
const data = await upload("image/*", "both");
|
||||
|
||||
if (data.type.includes("svg")) {
|
||||
const svg = new TextDecoder().decode(data.content.data);
|
||||
editor.handle.pasteSvg(data.filename, svg);
|
||||
return;
|
||||
}
|
||||
|
||||
// In case the user accidentally uploads a Graphite file, open it instead of failing to import it
|
||||
const graphiteFileSuffix = "." + editor.handle.fileExtension();
|
||||
if (data.filename.endsWith(graphiteFileSuffix)) {
|
||||
const documentName = data.filename.slice(0, -graphiteFileSuffix.length);
|
||||
editor.handle.openDocumentFile(documentName, data.content.text);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = await extractPixelData(new Blob([new Uint8Array(data.content.data)], { type: data.type }));
|
||||
editor.handle.pasteImage(data.filename, new Uint8Array(imageData.data), imageData.width, imageData.height);
|
||||
// TODO: Use the same `accept` string as in the `TriggerOpen` handler once importing Graphite documents as nodes is supported
|
||||
const data = await upload("image/*", "data");
|
||||
editor.handle.importFile(data.filename, data.content);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerSaveDocument, (data) => {
|
||||
downloadFile(data.name, data.content);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { type Editor } from "@graphite/editor";
|
||||
import { extractPixelData } from "@graphite/utility-functions/rasterization";
|
||||
|
||||
export function downloadFileURL(filename: string, url: string) {
|
||||
const element = document.createElement("a");
|
||||
|
||||
@@ -60,3 +63,19 @@ export async function upload<T extends "text" | "data" | "both">(accept: string,
|
||||
}
|
||||
export type UploadResult<T> = { filename: string; type: string; content: UploadResultType<T> };
|
||||
type UploadResultType<T> = T extends "text" ? string : T extends "data" ? Uint8Array : T extends "both" ? { text: string; data: Uint8Array } : never;
|
||||
|
||||
export async function pasteFile(item: DataTransferItem, editor: Editor, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
if (file.type.startsWith("image/svg")) {
|
||||
const svg = await file.text();
|
||||
editor.handle.pasteSvg(file.name, svg, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
|
||||
} else if (file.type.startsWith("image/")) {
|
||||
const imageData = await extractPixelData(file);
|
||||
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
|
||||
} else if (file.name.endsWith("." + editor.handle.fileExtension())) {
|
||||
// TODO: When we eventually have sub-documents, this should be changed to import the document as a node instead of opening it in a separate tab
|
||||
editor.handle.openFile(file.name, await file.bytes());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user