mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-04 05:18:19 +00:00
Add support for saving and opening files (#325)
* Add support for saving a document This is similar to the "export" functionality, except that we store all metadata needed to open the file again. Currently we store the internal representation of the layer which is probably pretty fragile. Example document: ```json { "nodes": {}, "root": { "blend_mode": "Normal", "cache": "...", "cache_dirty": false, "data": { "Folder": { "layer_ids": [ 3902938778642561358 ], "layers": [ { "blend_mode": "Normal", "cache": "...", "cache_dirty": false, "data": { "Shape": { "path": [ { "MoveTo": { "x": 0.0, "y": 0.0 } }, { "LineTo": { "x": 1.0, "y": 0.0 } }, { "LineTo": { "x": 1.0, "y": 1.0 } }, { "LineTo": { "x": 0.0, "y": 1.0 } }, "ClosePath" ], "render_index": 1, "solid": true, "style": { "fill": { "color": { "alpha": 1.0, "blue": 0.0, "green": 0.0, "red": 0.0 } }, "stroke": null } } }, "name": null, "opacity": 1.0, "thumbnail_cache": "...", "transform": { "matrix2": [ 223.0, 0.0, -0.0, 348.0 ], "translation": [ -188.0, -334.0 ] }, "visible": true } ], "next_assignment_id": 3902938778642561359 } }, "name": null, "opacity": 1.0, "thumbnail_cache": "...", "transform": { "matrix2": [ 1.0, 0.0, 0.0, 1.0 ], "translation": [ 479.0, 563.0 ] }, "visible": true }, "version": 0 } ``` * Add support for opening a saved document User can select a file using the browser's file input selector. We parse it as JSON and load it into the internal representation. Concerns: - The file format is fragile - Loading data directly into internal data structures usually creates security vulnerabilities - Error handling: The user is not informed of errors * Serialize Document and skip "cache" fields in Layer Instead of serializing the root layer, we serialize the Document struct directly. Additionally, we mark the "cache" fields in layer as "skip" fields so they don't get serialized. * Opened files use the filename as the tab title * Split "new document" and "open document" handling Open document needs name and content to be provided so having a different interface is cleaner. Also did some refactoring to reuse code. * Show error to user when a file fails to open * Clean up code: better variable naming and structure * Use document name for saved and exported files We pass through the document name in the export and save messages. Additionally, we check if the appropriate file suffixes (.graphite and .svg) need to be added before passing it to the frontend. * Refactor document name generation * Don't assign a default of 1 to Documents that start with something other than DEFAULT_DOCUMENT_NAME * Improve runtime complexity by using binary instead of linear search * Update Layer panel upon document selection * Add File>Open/Ctrl+O; File>Save (As)/Ctrl+(Shift)+S; browse filters extension; split out download()/upload() into files.ts; change unsaved close dialog text Co-authored-by: Dennis Kobert <dennis@kobert.dev> Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
parent
47af8d9bed
commit
42c3b1f6e9
18 changed files with 280 additions and 80 deletions
|
@ -211,7 +211,7 @@
|
|||
import { defineComponent } from "vue";
|
||||
|
||||
import { makeModifiersBitfield } from "@/utilities/input";
|
||||
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, ExportDocument, SetCanvasZoom, SetCanvasRotation } from "@/utilities/response-handler";
|
||||
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, SetCanvasZoom, SetCanvasRotation } from "@/utilities/response-handler";
|
||||
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
||||
import { comingSoon } from "@/utilities/errors";
|
||||
|
||||
|
@ -300,37 +300,25 @@ export default defineComponent({
|
|||
async resetWorkingColors() {
|
||||
(await wasm).reset_colors();
|
||||
},
|
||||
download(filename: string, fileData: string) {
|
||||
const svgBlob = new Blob([fileData], { type: "image/svg+xml;charset=utf-8" });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
const element = document.createElement("a");
|
||||
|
||||
element.href = svgUrl;
|
||||
element.setAttribute("download", filename);
|
||||
element.style.display = "none";
|
||||
|
||||
element.click();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
registerResponseHandler(ResponseType.UpdateCanvas, (responseData: Response) => {
|
||||
const updateData = responseData as UpdateCanvas;
|
||||
if (updateData) this.viewportSvg = updateData.document;
|
||||
});
|
||||
registerResponseHandler(ResponseType.ExportDocument, (responseData: Response) => {
|
||||
const updateData = responseData as ExportDocument;
|
||||
if (updateData) this.download("canvas.svg", updateData.document);
|
||||
});
|
||||
|
||||
registerResponseHandler(ResponseType.SetActiveTool, (responseData: Response) => {
|
||||
const toolData = responseData as SetActiveTool;
|
||||
if (toolData) this.activeTool = toolData.tool_name;
|
||||
});
|
||||
|
||||
registerResponseHandler(ResponseType.SetCanvasZoom, (responseData: Response) => {
|
||||
const updateData = responseData as SetCanvasZoom;
|
||||
if (updateData) {
|
||||
this.documentZoom = updateData.new_zoom * 100;
|
||||
}
|
||||
});
|
||||
|
||||
registerResponseHandler(ResponseType.SetCanvasRotation, (responseData: Response) => {
|
||||
const updateData = responseData as SetCanvasRotation;
|
||||
if (updateData) {
|
||||
|
|
|
@ -69,7 +69,7 @@ const menuEntries: MenuListEntries = [
|
|||
children: [
|
||||
[
|
||||
{ label: "New", icon: "File", shortcut: ["Ctrl", "N"], shortcutRequiresLock: true, action: async () => (await wasm).new_document() },
|
||||
{ label: "Open…", shortcut: ["Ctrl", "O"] },
|
||||
{ label: "Open…", shortcut: ["Ctrl", "O"], action: async () => (await wasm).open_document() },
|
||||
{
|
||||
label: "Open Recent",
|
||||
shortcut: ["Ctrl", "⇧", "O"],
|
||||
|
@ -90,8 +90,8 @@ const menuEntries: MenuListEntries = [
|
|||
{ label: "Close All", shortcut: ["Ctrl", "Alt", "W"], action: async () => (await wasm).close_all_documents_with_confirmation() },
|
||||
],
|
||||
[
|
||||
{ label: "Save", shortcut: ["Ctrl", "S"] },
|
||||
{ label: "Save As…", shortcut: ["Ctrl", "⇧", "S"] },
|
||||
{ label: "Save", shortcut: ["Ctrl", "S"], action: async () => (await wasm).save_document() },
|
||||
{ label: "Save As…", shortcut: ["Ctrl", "⇧", "S"], action: async () => (await wasm).save_document() },
|
||||
{ label: "Save All", shortcut: ["Ctrl", "Alt", "S"] },
|
||||
{ label: "Auto-Save", checkbox: true, checked: true },
|
||||
],
|
||||
|
@ -128,10 +128,10 @@ const menuEntries: MenuListEntries = [
|
|||
label: "Order",
|
||||
children: [
|
||||
[
|
||||
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers(2147483647) },
|
||||
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers((await wasm).i32_max()) },
|
||||
{ label: "Raise", shortcut: ["Ctrl", "]"], action: async () => (await wasm).reorder_selected_layers(1) },
|
||||
{ label: "Lower", shortcut: ["Ctrl", "["], action: async () => (await wasm).reorder_selected_layers(-1) },
|
||||
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers(-2147483648) },
|
||||
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers((await wasm).i32_min()) },
|
||||
],
|
||||
],
|
||||
},
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import { reactive, readonly } from "vue";
|
||||
|
||||
import { createDialog, dismissDialog } from "@/utilities/dialog";
|
||||
import { ResponseType, registerResponseHandler, Response, SetActiveDocument, UpdateOpenDocumentsList, DisplayConfirmationToCloseDocument } from "@/utilities/response-handler";
|
||||
import {
|
||||
ResponseType,
|
||||
registerResponseHandler,
|
||||
Response,
|
||||
SetActiveDocument,
|
||||
UpdateOpenDocumentsList,
|
||||
DisplayConfirmationToCloseDocument,
|
||||
ExportDocument,
|
||||
SaveDocument,
|
||||
} from "@/utilities/response-handler";
|
||||
import { download, upload } from "./files";
|
||||
|
||||
const wasm = import("@/../wasm/pkg");
|
||||
|
||||
|
@ -21,15 +31,14 @@ export async function closeDocumentWithConfirmation(tabIndex: number) {
|
|||
|
||||
const tabLabel = state.documents[tabIndex];
|
||||
|
||||
// TODO: Rename to "Save changes before closing?" when we can actually save documents somewhere, not just export SVGs
|
||||
createDialog("File", "Close without exporting SVG?", tabLabel, [
|
||||
createDialog("File", "Save changes before closing?", tabLabel, [
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
(await wasm).export_document();
|
||||
(await wasm).save_document();
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Export", emphasized: true, minWidth: 96 },
|
||||
props: { label: "Save", emphasized: true, minWidth: 96 },
|
||||
},
|
||||
{
|
||||
kind: "TextButton",
|
||||
|
@ -78,6 +87,7 @@ registerResponseHandler(ResponseType.UpdateOpenDocumentsList, (responseData: Res
|
|||
state.title = state.documents[state.activeDocumentIndex];
|
||||
}
|
||||
});
|
||||
|
||||
registerResponseHandler(ResponseType.SetActiveDocument, (responseData: Response) => {
|
||||
const documentData = responseData as SetActiveDocument;
|
||||
if (documentData) {
|
||||
|
@ -85,12 +95,30 @@ registerResponseHandler(ResponseType.SetActiveDocument, (responseData: Response)
|
|||
state.title = state.documents[state.activeDocumentIndex];
|
||||
}
|
||||
});
|
||||
|
||||
registerResponseHandler(ResponseType.DisplayConfirmationToCloseDocument, (responseData: Response) => {
|
||||
const data = responseData as DisplayConfirmationToCloseDocument;
|
||||
closeDocumentWithConfirmation(data.document_index);
|
||||
});
|
||||
registerResponseHandler(ResponseType.DisplayConfirmationToCloseAllDocuments, (_responseData: Response) => {
|
||||
|
||||
registerResponseHandler(ResponseType.DisplayConfirmationToCloseAllDocuments, (_: Response) => {
|
||||
closeAllDocumentsWithConfirmation();
|
||||
});
|
||||
|
||||
registerResponseHandler(ResponseType.OpenDocumentBrowse, async (_: Response) => {
|
||||
const extension = (await wasm).file_save_suffix();
|
||||
const data = await upload(extension);
|
||||
(await wasm).open_document_file(data.filename, data.content);
|
||||
});
|
||||
|
||||
registerResponseHandler(ResponseType.ExportDocument, (responseData: Response) => {
|
||||
const updateData = responseData as ExportDocument;
|
||||
if (updateData) download(updateData.name, updateData.document);
|
||||
});
|
||||
|
||||
registerResponseHandler(ResponseType.SaveDocument, (responseData: Response) => {
|
||||
const saveData = responseData as SaveDocument;
|
||||
if (saveData) download(saveData.name, saveData.document);
|
||||
});
|
||||
|
||||
(async () => (await wasm).get_open_documents_list())();
|
||||
|
|
39
frontend/src/utilities/files.ts
Normal file
39
frontend/src/utilities/files.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
export function download(filename: string, fileData: string) {
|
||||
let type = "text/plain;charset=utf-8";
|
||||
if (filename.endsWith(".svg")) {
|
||||
type = "image/svg+xml;charset=utf-8";
|
||||
}
|
||||
const blob = new Blob([fileData], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const element = document.createElement("a");
|
||||
|
||||
element.href = url;
|
||||
element.setAttribute("download", filename);
|
||||
element.style.display = "none";
|
||||
|
||||
element.click();
|
||||
}
|
||||
|
||||
export async function upload(acceptedEextensions: string) {
|
||||
return new Promise<{ filename: string; content: string }>((resolve, _) => {
|
||||
const element = document.createElement("input");
|
||||
element.type = "file";
|
||||
element.style.display = "none";
|
||||
element.accept = acceptedEextensions;
|
||||
|
||||
element.addEventListener(
|
||||
"change",
|
||||
async () => {
|
||||
if (!element.files || !element.files.length) return;
|
||||
const file = element.files[0];
|
||||
const filename = file.name;
|
||||
const content = await file.text();
|
||||
|
||||
resolve({ filename, content });
|
||||
},
|
||||
{ capture: false, once: true }
|
||||
);
|
||||
|
||||
element.click();
|
||||
});
|
||||
}
|
|
@ -15,6 +15,8 @@ const state = reactive({
|
|||
export enum ResponseType {
|
||||
UpdateCanvas = "UpdateCanvas",
|
||||
ExportDocument = "ExportDocument",
|
||||
SaveDocument = "SaveDocument",
|
||||
OpenDocumentBrowse = "OpenDocumentBrowse",
|
||||
ExpandFolder = "ExpandFolder",
|
||||
CollapseFolder = "CollapseFolder",
|
||||
UpdateLayer = "UpdateLayer",
|
||||
|
@ -72,6 +74,10 @@ function parseResponse(responseType: string, data: any): Response {
|
|||
return newSetCanvasRotation(data.SetCanvasRotation);
|
||||
case "ExportDocument":
|
||||
return newExportDocument(data.ExportDocument);
|
||||
case "SaveDocument":
|
||||
return newSaveDocument(data.SaveDocument);
|
||||
case "OpenDocumentBrowse":
|
||||
return newOpenDocumentBrowse(data.OpenDocumentBrowse);
|
||||
case "UpdateWorkingColors":
|
||||
return newUpdateWorkingColors(data.UpdateWorkingColors);
|
||||
case "DisplayError":
|
||||
|
@ -167,13 +173,31 @@ function newUpdateCanvas(input: any): UpdateCanvas {
|
|||
|
||||
export interface ExportDocument {
|
||||
document: string;
|
||||
name: string;
|
||||
}
|
||||
function newExportDocument(input: any): UpdateCanvas {
|
||||
function newExportDocument(input: any): ExportDocument {
|
||||
return {
|
||||
document: input.document,
|
||||
name: input.name,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SaveDocument {
|
||||
document: string;
|
||||
name: string;
|
||||
}
|
||||
function newSaveDocument(input: any): SaveDocument {
|
||||
return {
|
||||
document: input.document,
|
||||
name: input.name,
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenDocumentBrowse = {};
|
||||
function newOpenDocumentBrowse(_: any): OpenDocumentBrowse {
|
||||
return {};
|
||||
}
|
||||
|
||||
export type DocumentChanged = {};
|
||||
function newDocumentChanged(_: any): DocumentChanged {
|
||||
return {};
|
||||
|
|
|
@ -69,6 +69,21 @@ pub fn new_document() -> Result<(), JsValue> {
|
|||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentsMessage::NewDocument).map_err(convert_error))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn open_document() -> Result<(), JsValue> {
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentsMessage::OpenDocument).map_err(convert_error))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn open_document_file(name: String, content: String) -> Result<(), JsValue> {
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentsMessage::OpenDocumentFile(name, content)).map_err(convert_error))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn save_document() -> Result<(), JsValue> {
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::SaveDocument)).map_err(convert_error)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn close_document(document: usize) -> Result<(), JsValue> {
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentsMessage::CloseDocument(document)).map_err(convert_error))
|
||||
|
|
|
@ -1,9 +1,25 @@
|
|||
use crate::shims::Error;
|
||||
use editor::consts::FILE_SAVE_SUFFIX;
|
||||
use editor::input::keyboard::Key;
|
||||
use editor::tool::{SelectAppendMode, ToolType};
|
||||
use editor::Color as InnerColor;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn file_save_suffix() -> String {
|
||||
FILE_SAVE_SUFFIX.into()
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn i32_max() -> i32 {
|
||||
i32::MAX
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn i32_min() -> i32 {
|
||||
i32::MIN
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Color(InnerColor);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue