diff --git a/crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm b/crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm index 2437a057e6..dd652e305a 100755 --- a/crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm +++ b/crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a26855f25af8aaf11af39419fcb537292e13bde94d6345bcd23502bd0cebbe1a -size 12866529 +oid sha256:beea24d884591a1ea9e50ee1092a47d9c7fac7c36967f7357b0029f8a0563077 +size 12869282 diff --git a/crates/grida-canvas-wasm/lib/index.ts b/crates/grida-canvas-wasm/lib/index.ts index 6ba7ba2563..b29733d2c6 100644 --- a/crates/grida-canvas-wasm/lib/index.ts +++ b/crates/grida-canvas-wasm/lib/index.ts @@ -39,17 +39,56 @@ export namespace types { }; export type ExportConstraints = { - type: "SCALE" | "WIDTH" | "HEIGHT"; + /** + * - none: as-is, no resizing, scaling + * - scale: scale with factor + * - scale-to-fit-width: scale to fit width (with same aspect ratio) + * - scale-to-fit-height: scale to fit height (with same aspect ratio) + */ + type: "none" | "scale" | "scale-to-fit-width" | "scale-to-fit-height"; + /** + * - scale: scale factor + * - scale-to-fit-width: width in pixels + * - scale-to-fit-height: height in pixels + */ value: number; }; export type ExportAs = ExportAsImage | ExportAsPDF | ExportAsSVG; export type ExportAsPDF = { format: "PDF" }; export type ExportAsSVG = { format: "SVG" }; - export type ExportAsImage = { - format: "PNG" | "JPEG" | "WEBP" | "BMP"; + export type ExportAsPNG = { + format: "PNG"; constraints: ExportConstraints; }; + export type ExportAsJPEG = { + format: "JPEG"; + constraints: ExportConstraints; + /** + * Quality setting for JPEG compression (0-100). Higher values mean better quality but larger file size. + * @default 100 + */ + quality?: number; + }; + export type ExportAsWEBP = { + format: "WEBP"; + constraints: ExportConstraints; + /** + * Quality setting for WEBP compression (0-100). Higher values mean better quality but larger file size. + * Quality 100 is lossless. Lower values use lossy compression. + * @default 75 + */ + quality?: number; + }; + export type ExportAsBMP = { + format: "BMP"; + constraints: ExportConstraints; + }; + export type ExportAsImage = + | ExportAsPNG + | ExportAsJPEG + | ExportAsWEBP + | ExportAsBMP; export type FontKey = { family: string; diff --git a/crates/grida-canvas-wasm/package.json b/crates/grida-canvas-wasm/package.json index 12dfd1d719..427ccd5781 100644 --- a/crates/grida-canvas-wasm/package.json +++ b/crates/grida-canvas-wasm/package.json @@ -1,7 +1,7 @@ { "name": "@grida/canvas-wasm", "description": "WASM bindings for Grida Canvas", - "version": "0.89.0-canary.2", + "version": "0.89.0-canary.3", "keywords": [ "grida", "canvas", diff --git a/crates/grida-canvas/src/export/export_as_image.rs b/crates/grida-canvas/src/export/export_as_image.rs index 6ef921b2ec..947381b3c7 100644 --- a/crates/grida-canvas/src/export/export_as_image.rs +++ b/crates/grida-canvas/src/export/export_as_image.rs @@ -1,3 +1,4 @@ +use crate::node::schema::Size; use crate::{ export::{ExportAsImage, ExportSize, Exported}, node::schema::Scene, @@ -32,9 +33,19 @@ pub fn export_node_as_image( ) -> Option { let skfmt: EncodedImageFormat = format.clone().into(); - let camera = Camera2D::new_from_bounds(rect); + // Create camera with original bounds to determine world-space view + let mut camera = Camera2D::new_from_bounds(rect); + + // Scale the camera size to target resolution and adjust zoom to maintain same world-space view + // When we increase the viewport size and zoom IN proportionally, we see the same world-space rect + // but at higher resolution (scale = 2 means 2x zoom, 2x pixels, same world-space view) + let scale = size.width / rect.width; + camera.set_size(Size { + width: size.width, + height: size.height, + }); + camera.set_zoom(scale); - // 2. create a renderer sharing the font repository's ByteStore let store = fonts.store(); let mut r = Renderer::new_with_store( Backend::new_from_raster(size.width as i32, size.height as i32), @@ -47,14 +58,23 @@ pub fn export_node_as_image( r.fonts = fonts.clone(); r.images = images.clone(); r.load_scene(scene.clone()); + + // Render directly at target resolution - this ensures fonts work correctly let image = r.snapshot(); + r.free(); - let Some(data) = image.encode(None, skfmt, None) else { - r.free(); + // Extract quality for JPEG and WEBP formats + let quality = match &format { + ExportAsImage::JPEG(jpeg_config) => jpeg_config.quality, + ExportAsImage::WEBP(webp_config) => webp_config.quality, + _ => None, + }; + + let Some(data) = image.encode(None, skfmt, quality) else { return None; }; - // 2. export node + // Return the exported data let exported = match format { ExportAsImage::PNG(_) => Some(Exported::PNG(data.to_vec())), ExportAsImage::JPEG(_) => Some(Exported::JPEG(data.to_vec())), @@ -62,7 +82,5 @@ pub fn export_node_as_image( ExportAsImage::BMP(_) => Some(Exported::BMP(data.to_vec())), }; - r.free(); - exported } diff --git a/crates/grida-canvas/src/export/export_as_pdf.rs b/crates/grida-canvas/src/export/export_as_pdf.rs index 3eaee1a3fa..5b3ff0f22f 100644 --- a/crates/grida-canvas/src/export/export_as_pdf.rs +++ b/crates/grida-canvas/src/export/export_as_pdf.rs @@ -4,6 +4,7 @@ use crate::{ runtime::{ camera::Camera2D, font_repository::FontRepository, + image_repository::ImageRepository, scene::{Backend, Renderer, RendererOptions}, }, }; @@ -14,6 +15,7 @@ use std::io::Cursor; pub fn export_node_as_pdf( scene: &Scene, fonts: &FontRepository, + images: &ImageRepository, rect: Rectangle, _options: ExportAsPDF, ) -> Option { @@ -45,6 +47,7 @@ pub fn export_node_as_pdf( ); renderer.fonts = fonts.clone(); + renderer.images = images.clone(); // Load the scene renderer.load_scene(scene.clone()); diff --git a/crates/grida-canvas/src/export/export_as_svg.rs b/crates/grida-canvas/src/export/export_as_svg.rs index 01eb2dd68e..fd5a61f98e 100644 --- a/crates/grida-canvas/src/export/export_as_svg.rs +++ b/crates/grida-canvas/src/export/export_as_svg.rs @@ -4,6 +4,7 @@ use crate::{ runtime::{ camera::Camera2D, font_repository::FontRepository, + image_repository::ImageRepository, scene::{Backend, Renderer, RendererOptions}, }, }; @@ -13,6 +14,7 @@ use skia_safe::{svg, Rect as SkRect}; pub fn export_node_as_svg( scene: &Scene, fonts: &FontRepository, + images: &ImageRepository, rect: Rectangle, _options: ExportAsSVG, ) -> Option { @@ -37,6 +39,7 @@ pub fn export_node_as_svg( ); renderer.fonts = fonts.clone(); + renderer.images = images.clone(); renderer.load_scene(scene.clone()); renderer.render_to_canvas(&canvas, width, height); diff --git a/crates/grida-canvas/src/export/mod.rs b/crates/grida-canvas/src/export/mod.rs index 7903ac7460..d8f1bc519e 100644 --- a/crates/grida-canvas/src/export/mod.rs +++ b/crates/grida-canvas/src/export/mod.rs @@ -101,13 +101,13 @@ pub fn export_node_as( ExportAs::PDF(pdf_format) => pdf_format, _ => unreachable!(), }; - return export_node_as_pdf(scene, fonts, rect, format); + return export_node_as_pdf(scene, fonts, images, rect, format); } else if format.is_format_svg() { let format: ExportAsSVG = match format { ExportAs::SVG(svg_format) => svg_format, _ => unreachable!(), }; - return export_node_as_svg(scene, fonts, rect, format); + return export_node_as_svg(scene, fonts, images, rect, format); } else if format.is_format_image() { let format: ExportAsImage = format.clone().try_into().unwrap(); return export_node_as_image(scene, fonts, images, size, rect, format); diff --git a/crates/grida-canvas/src/export/types.rs b/crates/grida-canvas/src/export/types.rs index 8ae4de07fd..67a61322bb 100644 --- a/crates/grida-canvas/src/export/types.rs +++ b/crates/grida-canvas/src/export/types.rs @@ -3,13 +3,13 @@ use serde::Deserialize; #[derive(Clone, Deserialize)] #[serde(tag = "type", content = "value")] pub enum ExportConstraints { - #[serde(rename = "NONE")] + #[serde(rename = "none")] None, - #[serde(rename = "SCALE")] + #[serde(rename = "scale")] Scale(f32), - #[serde(rename = "WIDTH")] + #[serde(rename = "scale-to-fit-width")] ScaleToWidth(f32), - #[serde(rename = "HEIGHT")] + #[serde(rename = "scale-to-fit-height")] ScaleToHeight(f32), } @@ -29,11 +29,19 @@ impl Default for ExportAsPNG { #[derive(Clone, Deserialize)] pub struct ExportAsJPEG { pub(crate) constraints: ExportConstraints, + + /// 0-100, None means use Skia default (100) + #[serde(default)] + pub(crate) quality: Option, } #[derive(Clone, Deserialize)] pub struct ExportAsWEBP { pub(crate) constraints: ExportConstraints, + + /// 0-100, None means use Skia default (75) + #[serde(default)] + pub(crate) quality: Option, } #[derive(Clone, Deserialize)] @@ -106,8 +114,11 @@ impl ExportAs { Self::PNG(ExportAsPNG::default()) } - pub fn jpeg(constraints: ExportConstraints) -> Self { - Self::JPEG(ExportAsJPEG { constraints }) + pub fn jpeg(constraints: ExportConstraints, quality: Option) -> Self { + Self::JPEG(ExportAsJPEG { + constraints, + quality, + }) } pub fn pdf() -> Self { diff --git a/editor/grida-canvas-react/index.ts b/editor/grida-canvas-react/index.ts index cff15b7a0c..340d3604d1 100644 --- a/editor/grida-canvas-react/index.ts +++ b/editor/grida-canvas-react/index.ts @@ -5,6 +5,7 @@ export { useNode, useBrushState, useComputedNode, + useNodeMetadata, useNodeActions, useTransformState, useToolState, diff --git a/editor/grida-canvas-react/provider.tsx b/editor/grida-canvas-react/provider.tsx index 7eb52b3708..896be406a7 100644 --- a/editor/grida-canvas-react/provider.tsx +++ b/editor/grida-canvas-react/provider.tsx @@ -101,7 +101,7 @@ export function useNodeActions(node_id: string | undefined) { instance.commands.changeNodePropertyProps(node_id, key, value), // attributes userdata: (value: any) => - instance.commands.changeNodeUserData(node_id, value), + instance.setUserData(node_id, value as Record | null), name: (name: string) => { node.name = name; }, @@ -857,3 +857,24 @@ export function useTemplateDefinition(template_id: string) { return templates![template_id]; } + +/** + * Hook to access node metadata by namespace + * @param node_id - The node ID + * @param namespace - The metadata namespace ("export_settings" | "userdata") + * @returns The metadata value for the specified namespace, or undefined if not set + */ +export function useNodeMetadata( + node_id: string, + namespace: NS +): NS extends "export_settings" + ? grida.program.document.NodeExportSettings[] | undefined + : NS extends "userdata" + ? Record | null | undefined + : never { + const editor = useCurrentEditor(); + return useEditorState( + editor, + (state) => state.document.metadata?.[node_id]?.[namespace] + ) as any; +} diff --git a/editor/grida-canvas-react/use-mixed-properties.ts b/editor/grida-canvas-react/use-mixed-properties.ts index 4c50497285..502e1fe208 100644 --- a/editor/grida-canvas-react/use-mixed-properties.ts +++ b/editor/grida-canvas-react/use-mixed-properties.ts @@ -35,7 +35,7 @@ export function useMixedProperties(ids: string[]) { nodes as grida.program.nodes.UnknwonNode[], { idKey: "id", - ignoredKey: ["id", "type", "userdata"], + ignoredKey: ["id", "type"], mixed: grida.mixed, } ), diff --git a/editor/grida-canvas/action.ts b/editor/grida-canvas/action.ts index 49933a7f4e..99d70ffd66 100644 --- a/editor/grida-canvas/action.ts +++ b/editor/grida-canvas/action.ts @@ -73,7 +73,9 @@ export type DocumentAction = | TemplateNodeOverrideChangeAction | TemplateEditorSetTemplatePropsAction // - | SchemaAction; + | SchemaAction + // + | MetadataAction; type NodeID = string & {}; type Vector2 = [number, number]; @@ -1052,3 +1054,23 @@ export interface DocumentSchemaDeletePropertyAction { type: "document/properties/delete"; key: string; } + +export type MetadataAction = + | { + type: "node-metadata/set"; + node_id: string; + namespace: "export_settings"; + data: grida.program.document.NodeExportSettings[]; + } + | { + type: "node-metadata/set"; + node_id: string; + namespace: "userdata"; + data: Record | null; + } + | { + type: "node-metadata/remove"; + node_id: string; + namespace: "export_settings" | "userdata"; + } + | { type: "node-metadata/remove-all"; node_id: string }; diff --git a/editor/grida-canvas/backends/dom-export.ts b/editor/grida-canvas/backends/dom-export.ts index d25cbfb664..330d822fab 100644 --- a/editor/grida-canvas/backends/dom-export.ts +++ b/editor/grida-canvas/backends/dom-export.ts @@ -2,6 +2,7 @@ import { toPng, toSvg, toJpeg } from "html-to-image"; import type { Options } from "html-to-image/lib/types"; import type { editor } from ".."; import type { Editor } from "../editor"; +import type grida from "@grida/schema"; import assert from "assert"; export async function exportAsImage( @@ -99,7 +100,11 @@ export class DOMSVGExportInterfaceProvider export class DOMDefaultExportInterfaceProvider implements editor.api.IDocumentExporterInterfaceProvider { - readonly formats = ["PNG", "JPEG", "PDF", "SVG"]; + readonly formats: grida.program.document.NodeExportSettings["format"][] = [ + "PNG", + "JPEG", + "SVG", + ]; readonly image_export: DOMImageExportInterfaceProvider; readonly svg_export: DOMSVGExportInterfaceProvider; @@ -111,16 +116,19 @@ export class DOMDefaultExportInterfaceProvider canExportNodeAs( _node_id: string, - format: "PNG" | "JPEG" | "PDF" | "SVG" | (string & {}) + format: grida.program.document.NodeExportSettings["format"] | (string & {}) ): boolean { - return this.formats.includes(format); + return this.formats.includes(format as any); } - async exportNodeAs( + async exportNodeAs< + F extends grida.program.document.NodeExportSettings["format"], + >( node_id: string, - format: "PNG" | "JPEG" | "PDF" | "SVG" | (string & {}) - ): Promise { - assert(this.formats.includes(format), "non supported format"); + format: F, + _config?: editor.api.ExportConfigOf + ): Promise { + assert(this.formats.includes(format as any), "non supported format"); switch (format) { case "PNG": @@ -128,13 +136,24 @@ export class DOMDefaultExportInterfaceProvider return this.image_export.exportNodeAsImage( node_id, format as "PNG" | "JPEG" - ); + ) as Promise; } case "SVG": { - return this.svg_export.exportNodeAsSVG(node_id); + return this.svg_export.exportNodeAsSVG(node_id) as Promise< + F extends "SVG" ? string : Uint8Array + >; + } + case "PDF": { + // PDF export not supported in DOM backend + throw new Error("PDF export not supported in DOM backend"); + } + case "WEBP": + case "BMP": { + // WEBP and BMP export not supported in DOM backend + throw new Error(`${format} export not supported in DOM backend`); } } - throw new Error("Non supported format"); + throw new Error(`Non supported format: ${format}`); } } diff --git a/editor/grida-canvas/backends/noop.ts b/editor/grida-canvas/backends/noop.ts index 3a57068e64..8d822cb7dc 100644 --- a/editor/grida-canvas/backends/noop.ts +++ b/editor/grida-canvas/backends/noop.ts @@ -1,4 +1,5 @@ import cmath from "@grida/cmath"; +import type grida from "@grida/schema"; import type { editor } from ".."; export class NoopGeometryQueryInterfaceProvider @@ -33,10 +34,11 @@ export class NoopDefaultExportInterfaceProvider return false; } - exportNodeAs( + exportNodeAs( _node_id: string, - _format: "PNG" | "JPEG" | "PDF" | "SVG" | (string & {}) - ): Promise { + _format: F, + _config?: editor.api.ExportConfigOf + ): Promise { throw new Error("Not implemented"); } } diff --git a/editor/grida-canvas/backends/wasm.ts b/editor/grida-canvas/backends/wasm.ts index cdae733e14..9d375c469d 100644 --- a/editor/grida-canvas/backends/wasm.ts +++ b/editor/grida-canvas/backends/wasm.ts @@ -3,6 +3,8 @@ import type { editor } from ".."; import type { Editor } from "../editor"; import type { Scene, svgtypes } from "@grida/canvas-wasm"; import type vn from "@grida/vn"; +import type grida from "@grida/schema"; +import type { types } from "@grida/canvas-wasm"; import { UnifiedFontManager, type FontAdapter, @@ -45,7 +47,14 @@ export class CanvasWasmGeometryQueryInterfaceProvider export class CanvasWasmDefaultExportInterfaceProvider implements editor.api.IDocumentExporterInterfaceProvider { - readonly formats = ["PNG", "JPEG", "PDF", "SVG"]; + readonly formats: grida.program.document.NodeExportSettings["format"][] = [ + "PNG", + "JPEG", + "PDF", + "SVG", + "WEBP", + "BMP", + ]; constructor( readonly editor: Editor, @@ -53,23 +62,45 @@ export class CanvasWasmDefaultExportInterfaceProvider ) {} canExportNodeAs( - node_id: string, - format: "PNG" | "JPEG" | "PDF" | "SVG" | (string & {}) + _node_id: string, + format: grida.program.document.NodeExportSettings["format"] | (string & {}) ): boolean { - return this.formats.includes(format); + return this.formats.includes(format as any); } async exportNodeAsImage( node_id: string, - format: "PNG" | "JPEG" + format: "PNG" | "JPEG" | "WEBP" | "BMP", + config?: editor.api.ExportConfigOf<"PNG" | "JPEG" | "WEBP" | "BMP"> ): Promise { - const data = await this.surface.exportNodeAs(node_id, { - format: format, - constraints: { - type: "SCALE", - value: 1, - }, - }); + const constraints: types.ExportConstraints = config?.constraints || { + type: "scale", + value: 1, + }; + + // Build format-specific export config + let exportFormat: types.ExportAs; + switch (format) { + // lossless formats + case "BMP": + case "PNG": + exportFormat = { format: "PNG", constraints }; + break; + // with quality + case "JPEG": + case "WEBP": + exportFormat = { + format: "JPEG", + constraints, + quality: config?.quality, + }; + break; + + default: + throw new Error(`Unsupported image format: ${format}`); + } + + const data = await this.surface.exportNodeAs(node_id, exportFormat); return data.data; } @@ -88,23 +119,34 @@ export class CanvasWasmDefaultExportInterfaceProvider return str; } - exportNodeAs( + exportNodeAs( node_id: string, - format: "PNG" | "JPEG" | "PDF" | "SVG" | (string & {}) - ): Promise { + format: F, + config?: editor.api.ExportConfigOf + ): Promise { switch (format) { case "PNG": - case "JPEG": { - return this.exportNodeAsImage(node_id, format as "PNG" | "JPEG"); + case "JPEG": + case "WEBP": + case "BMP": { + return this.exportNodeAsImage( + node_id, + format as "PNG" | "JPEG" | "WEBP" | "BMP", + config as editor.api.ExportConfigOf<"PNG" | "JPEG" | "WEBP" | "BMP"> + ) as Promise; } case "PDF": { - return this.exportNodeAsPDF(node_id); + return this.exportNodeAsPDF(node_id) as Promise< + F extends "SVG" ? string : Uint8Array + >; } case "SVG": { - return this.exportNodeAsSVG(node_id); + return this.exportNodeAsSVG(node_id) as Promise< + F extends "SVG" ? string : Uint8Array + >; } default: { - throw new Error("Non supported format"); + throw new Error(`Non supported format: ${format}`); } } } diff --git a/editor/grida-canvas/editor.i.ts b/editor/grida-canvas/editor.i.ts index 9dbf000e68..ec8eb8cb37 100644 --- a/editor/grida-canvas/editor.i.ts +++ b/editor/grida-canvas/editor.i.ts @@ -2551,18 +2551,45 @@ export namespace editor.api { exportNodeAsPDF(node_id: string): Promise; } + /** + * Export config type helper that maps format F to its corresponding config type. + * Uses the schema types and transforms them to the runtime export config format. + */ + export type ExportConfigOf< + F extends grida.program.document.NodeExportSettings["format"], + > = F extends "PNG" | "JPEG" | "WEBP" | "BMP" + ? Omit< + grida.program.document.NodeExportSettings_Image, + "suffix" | "constraints" + > & { + format: F; + constraints: { + type: Exclude< + grida.program.document.NodeExportSettingsConstraints["type"], + "none" + >; + value: number; + }; + } + : F extends "PDF" + ? Pick + : F extends "SVG" + ? Pick + : never; + export interface IDocumentExporterInterfaceProvider { - readonly formats: "PNG" | "JPEG" | "PDF" | "SVG" | (string & {})[]; + readonly formats: grida.program.document.NodeExportSettings["format"][]; canExportNodeAs( node_id: string, - format: "PNG" | "JPEG" | "PDF" | "SVG" | (string & {}) + format: grida.program.document.NodeExportSettings["format"] ): boolean; - exportNodeAs( + exportNodeAs( node_id: string, - format: "PNG" | "JPEG" | "PDF" | "SVG" | (string & {}) - ): Promise; + format: F, + config?: ExportConfigOf + ): Promise; } /** @@ -3369,7 +3396,6 @@ export namespace editor.api { */ unlockAspectRatio(node_id: NodeID): void; - changeNodeUserData(node_id: NodeID, userdata: unknown): void; changeNodeSize( node_id: NodeID, axis: "width" | "height", @@ -3987,12 +4013,137 @@ export namespace editor.api { } export interface IDocumentExportPluginActions { - exportNodeAs( + exportNodeAs( node_id: string, - format: "PNG" | "JPEG" - ): Promise; - exportNodeAs(node_id: string, format: "PDF"): Promise; - exportNodeAs(node_id: string, format: "SVG"): Promise; + format: F, + config: ExportConfigOf + ): Promise; + } + + /** + * General-purpose metadata API for namespace-based metadata access. + * Supports multiple namespaces: `export_settings` and `userdata`. + * + * @template NS - The namespace type ("export_settings" | "userdata") + * @template T - The value type for the namespace + */ + export interface INodeMetadataActions { + /** + * Get metadata for a node by namespace + */ + getNodeMetadata( + node_id: grida.program.nodes.NodeID, + namespace: NS + ): NS extends "export_settings" + ? grida.program.document.NodeExportSettings[] | undefined + : NS extends "userdata" + ? Record | null | undefined + : never; + + /** + * Set metadata for a node by namespace + */ + setNodeMetadata( + node_id: grida.program.nodes.NodeID, + namespace: NS, + data: NS extends "export_settings" + ? grida.program.document.NodeExportSettings[] + : NS extends "userdata" + ? Record | null + : never + ): void; + + /** + * Remove metadata for a node by namespace + */ + removeNodeMetadata( + node_id: grida.program.nodes.NodeID, + namespace: "export_settings" | "userdata" + ): void; + + /** + * Get export settings for a node (convenience method) + */ + getExportSettings( + node_id: grida.program.nodes.NodeID + ): grida.program.document.NodeExportSettings[] | undefined; + + /** + * Set export settings for a node (convenience method) + */ + setExportSettings( + node_id: grida.program.nodes.NodeID, + settings: grida.program.document.NodeExportSettings[] + ): void; + + /** + * Remove export settings for a node (convenience method) + */ + removeExportSettings(node_id: grida.program.nodes.NodeID): void; + + /** + * Get userdata for a node (convenience method) + */ + getUserData( + node_id: grida.program.nodes.NodeID + ): Record | null | undefined; + + /** + * Set userdata for a node (convenience method) + */ + setUserData( + node_id: grida.program.nodes.NodeID, + data: Record | null + ): void; + + /** + * Remove userdata for a node (convenience method) + */ + removeUserData(node_id: grida.program.nodes.NodeID): void; + } + + /** + * High-level semantic API for export configuration. + * Wraps the metadata API with export-specific methods. + * Supports multiple export settings per node (like Figma). + */ + export interface IExportConfigActions { + /** + * Get all export configurations for a node + */ + getExportConfigs( + node_id: grida.program.nodes.NodeID + ): grida.program.document.NodeExportSettings[]; + + /** + * Add an export configuration to a node + */ + addExportConfig( + node_id: grida.program.nodes.NodeID, + config: grida.program.document.NodeExportSettings + ): void; + + /** + * Update an export configuration at a specific index + */ + updateExportConfig( + node_id: grida.program.nodes.NodeID, + index: number, + config: grida.program.document.NodeExportSettings + ): void; + + /** + * Remove an export configuration at a specific index + */ + removeExportConfig( + node_id: grida.program.nodes.NodeID, + index: number + ): void; + + /** + * Remove all export configurations for a node + */ + clearExportConfigs(node_id: grida.program.nodes.NodeID): void; } export interface ISurfaceMultiplayerFollowPluginActions { @@ -4007,6 +4158,133 @@ export namespace editor.api { } } +/** + * Internal export types and utilities. + * Centralizes all export-related types to avoid duplication and ensure consistency. + */ +export namespace editor.internal.export_settings { + /** + * All supported export formats + */ + export type Format = grida.program.document.NodeExportSettings["format"]; + + /** + * Image export formats (raster formats that support quality) + */ + export type ImageFormat = "PNG" | "JPEG" | "WEBP" | "BMP"; + + /** + * Vector export formats (do not support scale/quality) + */ + export type VectorFormat = "SVG" | "PDF"; + + /** + * Formats that support quality settings + */ + export type QualitySupportedFormat = "JPEG" | "WEBP"; + + /** + * Formats that support scale constraints + */ + export type ScaleSupportedFormat = ImageFormat; + + /** + * MIME types for each export format + */ + export const MIME_TYPES: Record, string> = { + PNG: "image/png", + JPEG: "image/jpeg", + PDF: "application/pdf", + SVG: "image/svg+xml", + WEBP: "image/webp", + BMP: "image/bmp", + } as const; + + /** + * All export formats (supported, will support) as an array + */ + export const ALL_FORMATS = [ + "PNG", + "JPEG", + "SVG", + "PDF", + "WEBP", + "BMP", + ] as const satisfies readonly Format[]; + + /** + * Image export formats as an array + */ + export const IMAGE_FORMATS: readonly ImageFormat[] = [ + "PNG", + "JPEG", + "WEBP", + "BMP", + ] as const; + + /** + * Formats that support quality settings + */ + export const QUALITY_SUPPORTED_FORMATS: readonly QualitySupportedFormat[] = [ + "JPEG", + "WEBP", + ] as const; + + /** + * Formats that support scale constraints + */ + export const SCALE_SUPPORTED_FORMATS: readonly ScaleSupportedFormat[] = [ + "PNG", + "JPEG", + "WEBP", + "BMP", + ] as const; + + /** + * Type guard to check if a format supports quality + */ + export function supportsQuality( + format: Format | undefined + ): format is QualitySupportedFormat { + return ( + format !== undefined && + QUALITY_SUPPORTED_FORMATS.includes(format as QualitySupportedFormat) + ); + } + + /** + * Type guard to check if a format supports scale + */ + export function supportsScale( + format: Format | undefined + ): format is ScaleSupportedFormat { + return ( + format !== undefined && + SCALE_SUPPORTED_FORMATS.includes(format as ScaleSupportedFormat) + ); + } + + /** + * Get file extension for a format + */ + export function getFileExtension(format: Format): string { + if (!format) { + return "png"; // Default fallback + } + return format.toLowerCase(); + } + + /** + * Get MIME type for a format + */ + export function getMimeType(format: Format): string { + if (!format) { + return "image/png"; // Default fallback + } + return MIME_TYPES[format]; + } +} + /** * * monospace (ascii) characters used to represent canvas nodes in terminal / plain txt output. diff --git a/editor/grida-canvas/editor.ts b/editor/grida-canvas/editor.ts index d6230a5d95..38d8994d52 100644 --- a/editor/grida-canvas/editor.ts +++ b/editor/grida-canvas/editor.ts @@ -1673,14 +1673,6 @@ class EditorDocumentStore }); } - changeNodeUserData(node_id: string, userdata: unknown) { - this.dispatch({ - type: "node/change/*", - node_id: node_id, - userdata: userdata as any, - }); - } - changeNodePropertyPositioning( node_id: string, positioning: Partial @@ -2563,7 +2555,9 @@ export class Editor editor.api.IDocumentVectorInterfaceActions, editor.api.IDocumentSVGInterfaceProvider, editor.api.IDocumentMarkdownInterfaceProvider, - editor.api.IEditorIntrospectActions + editor.api.IEditorIntrospectActions, + editor.api.INodeMetadataActions, + editor.api.IExportConfigActions { // private readonly listeners: Set<(editor: this, action?: Action) => void> = new Set(); private readonly logger: (...args: any[]) => void; @@ -3648,28 +3642,173 @@ export class Editor // ============================================================== // #region IExportPluginActions implementation // ============================================================== - exportNodeAs( - node_id: string, - format: "PNG" | "JPEG" - ): Promise; - exportNodeAs(node_id: string, format: "PDF"): Promise; - exportNodeAs(node_id: string, format: "SVG"): Promise; - async exportNodeAs( + exportNodeAs( node_id: string, - format: "PNG" | "JPEG" | "PDF" | "SVG" - ): Promise { - const supported_by_exporter = this.exporter.formats.includes(format); - if (!supported_by_exporter) return false; + format: F, + config: editor.api.ExportConfigOf + ): Promise { + // Validate format is supported by exporter + if (!this.exporter.formats.includes(format)) { + throw new Error(`Format "${format}" is not supported by the exporter`); + } - const can_export_request = this.exporter.canExportNodeAs(node_id, format); - if (!can_export_request) return false; + // Validate node can be exported + if (!this.exporter.canExportNodeAs(node_id, format)) { + throw new Error(`Node "${node_id}" cannot be exported as "${format}"`); + } - return this.exporter.exportNodeAs(node_id, format); + // Export with provided config - no metadata reading/writing + return this.exporter.exportNodeAs(node_id, format, config); } // ============================================================== // #endregion IExportPluginActions implementation // ============================================================== + // ============================================================== + // #region INodeMetadataActions implementation + // ============================================================== + getNodeMetadata( + node_id: string, + namespace: NS + ): NS extends "export_settings" + ? grida.program.document.NodeExportSettings[] | undefined + : NS extends "userdata" + ? Record | null | undefined + : never { + const metadata = this.doc.state.document.metadata?.[node_id]; + if (!metadata) { + return undefined as ReturnType>; + } + if (namespace === "export_settings") { + return metadata.export_settings as ReturnType< + typeof this.getNodeMetadata + >; + } + if (namespace === "userdata") { + return metadata.userdata as ReturnType>; + } + return undefined as ReturnType>; + } + + setNodeMetadata( + node_id: string, + namespace: NS, + data: NS extends "export_settings" + ? grida.program.document.NodeExportSettings[] + : NS extends "userdata" + ? Record | null + : never + ): void { + if (namespace === "export_settings") { + this.doc.dispatch({ + type: "node-metadata/set", + node_id, + namespace: "export_settings", + data: data as grida.program.document.NodeExportSettings[], + }); + } else if (namespace === "userdata") { + this.doc.dispatch({ + type: "node-metadata/set", + node_id, + namespace: "userdata", + data: data as Record | null, + }); + } + } + + removeNodeMetadata( + node_id: string, + namespace: "export_settings" | "userdata" + ): void { + this.doc.dispatch({ + type: "node-metadata/remove", + node_id, + namespace, + }); + } + + getExportSettings( + node_id: string + ): grida.program.document.NodeExportSettings[] | undefined { + return this.getNodeMetadata(node_id, "export_settings"); + } + + setExportSettings( + node_id: string, + settings: grida.program.document.NodeExportSettings[] + ): void { + this.setNodeMetadata(node_id, "export_settings", settings); + } + + removeExportSettings(node_id: string): void { + this.removeNodeMetadata(node_id, "export_settings"); + } + + getUserData(node_id: string): Record | null | undefined { + return this.getNodeMetadata(node_id, "userdata"); + } + + setUserData(node_id: string, data: Record | null): void { + this.setNodeMetadata(node_id, "userdata", data); + } + + removeUserData(node_id: string): void { + this.removeNodeMetadata(node_id, "userdata"); + } + // ============================================================== + // #endregion INodeMetadataActions implementation + // ============================================================== + + // ============================================================== + // #region IExportConfigActions implementation + // ============================================================== + getExportConfigs( + node_id: string + ): grida.program.document.NodeExportSettings[] { + const configs = this.getExportSettings(node_id); + return configs || []; + } + + addExportConfig( + node_id: string, + config: grida.program.document.NodeExportSettings + ): void { + const configs = this.getExportConfigs(node_id); + this.setExportSettings(node_id, [...configs, config]); + } + + updateExportConfig( + node_id: string, + index: number, + config: grida.program.document.NodeExportSettings + ): void { + const configs = this.getExportConfigs(node_id); + if (index >= 0 && index < configs.length) { + const updated = [...configs]; + updated[index] = config; + this.setExportSettings(node_id, updated); + } + } + + removeExportConfig(node_id: string, index: number): void { + const configs = this.getExportConfigs(node_id); + if (index >= 0 && index < configs.length) { + const updated = configs.filter((_, i) => i !== index); + if (updated.length > 0) { + this.setExportSettings(node_id, updated); + } else { + this.removeExportSettings(node_id); + } + } + } + + clearExportConfigs(node_id: string): void { + this.removeExportSettings(node_id); + } + // ============================================================== + // #endregion IExportConfigActions implementation + // ============================================================== + /** * Dispose editor instance and cleanup resources */ @@ -4391,7 +4530,10 @@ export class EditorSurface const ids = target === "selection" ? this.state.selection : [target]; if (ids.length === 0) return false; const id = ids[0]; - const data = await this._editor.exportNodeAs(id, "PNG"); + const data = await this._editor.exportNodeAs(id, "PNG", { + format: "PNG", + constraints: { type: "scale", value: 1 }, + }); const blob = new Blob([data as BlobPart], { type: "image/png" }); await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); return true; @@ -4407,7 +4549,7 @@ export class EditorSurface const ids = target === "selection" ? this.state.selection : [target]; if (ids.length === 0) return false; const id = ids[0]; - const data = await this._editor.exportNodeAs(id, "SVG"); + const data = await this._editor.exportNodeAs(id, "SVG", { format: "SVG" }); if (typeof data !== "string") { return false; } diff --git a/editor/grida-canvas/plugins/yjs/y-document.ts b/editor/grida-canvas/plugins/yjs/y-document.ts index 58cc3594e6..dab55df613 100644 --- a/editor/grida-canvas/plugins/yjs/y-document.ts +++ b/editor/grida-canvas/plugins/yjs/y-document.ts @@ -123,6 +123,7 @@ export class DocumentSyncManager { nodes: localDocument.nodes, scenes_ref: localDocument.scenes_ref, links: localDocument.links, + metadata: localDocument.metadata, }, }, ]); diff --git a/editor/grida-canvas/reducers/document.reducer.ts b/editor/grida-canvas/reducers/document.reducer.ts index f74c11c97b..478b1dd4fe 100644 --- a/editor/grida-canvas/reducers/document.reducer.ts +++ b/editor/grida-canvas/reducers/document.reducer.ts @@ -23,6 +23,8 @@ import assert from "assert"; import nodeReducer from "./node.reducer"; import surfaceReducer from "./surface.reducer"; import updateNodeTransform from "./node-transform.reducer"; +import metadataReducer from "./metadata.reducer"; +import schemaReducer from "./schema.reducer"; import { __validateHoverState } from "./methods/hover"; import { self_clearSelection, @@ -56,7 +58,6 @@ import cmath from "@grida/cmath"; import kolor from "@grida/color"; import { layout } from "@grida/cmath/_layout"; import { snapMovement } from "./tools/snap"; -import schemaReducer from "./schema.reducer"; import { self_moveNode } from "./methods/move"; import { v4 } from "uuid"; import type { ReducerContext } from "."; @@ -2034,6 +2035,20 @@ export default function documentReducer( }); } + case "node-metadata/set": + case "node-metadata/remove": + case "node-metadata/remove-all": { + return updateState(state, (draft) => { + if (!draft.document.metadata) { + draft.document.metadata = {}; + } + draft.document.metadata = metadataReducer( + draft.document.metadata, + action + ); + }); + } + default: { throw new Error( `unknown action type: "${(action as DocumentAction).type}"` diff --git a/editor/grida-canvas/reducers/metadata.reducer.ts b/editor/grida-canvas/reducers/metadata.reducer.ts new file mode 100644 index 0000000000..ef53e2860b --- /dev/null +++ b/editor/grida-canvas/reducers/metadata.reducer.ts @@ -0,0 +1,33 @@ +import type grida from "@grida/schema"; +import type { MetadataAction } from "../action"; + +export default function metadataReducer( + state: grida.program.document.INodeMetadata["metadata"] = {}, + action: MetadataAction +): grida.program.document.INodeMetadata["metadata"] { + switch (action.type) { + case "node-metadata/set": + return { + ...state, + [action.node_id]: { + ...state[action.node_id], + [action.namespace]: action.data, + }, + }; + case "node-metadata/remove": + const { [action.namespace]: removed, ...rest } = + state[action.node_id] || {}; + const newState = { ...state }; + if (Object.keys(rest).length > 0) { + newState[action.node_id] = rest; + } else { + delete newState[action.node_id]; + } + return newState; + case "node-metadata/remove-all": + const { [action.node_id]: _, ...remaining } = state; + return remaining; + default: + return state; + } +} diff --git a/editor/grida-canvas/reducers/node.reducer.ts b/editor/grida-canvas/reducers/node.reducer.ts index 7aa0b56d30..47ad2d103c 100644 --- a/editor/grida-canvas/reducers/node.reducer.ts +++ b/editor/grida-canvas/reducers/node.reducer.ts @@ -868,17 +868,6 @@ const safe_properties: Partial< (draft as UN).text = value ?? null; }, }), - userdata: defineNodeProperty<"userdata">({ - apply: (draft, value, prev) => { - assert( - value === undefined || - value === null || - (typeof value === "object" && !Array.isArray(value)), - "userdata must be an k:v object" - ); - (draft as UN).userdata = value; - }, - }), }; function applyNodeProperty( diff --git a/editor/grida-canvas/reducers/tools/initial-node.ts b/editor/grida-canvas/reducers/tools/initial-node.ts index a3276f1b50..3ecb4e9214 100644 --- a/editor/grida-canvas/reducers/tools/initial-node.ts +++ b/editor/grida-canvas/reducers/tools/initial-node.ts @@ -69,7 +69,6 @@ export default function initialNode( grida.program.nodes.i.ISceneNode = { id: id, name: type, - userdata: undefined, // locked: false, active: true, diff --git a/editor/scaffolds/sidecontrol/controls/export.tsx b/editor/scaffolds/sidecontrol/controls/export.tsx index bba484d000..fd03db8b9c 100644 --- a/editor/scaffolds/sidecontrol/controls/export.tsx +++ b/editor/scaffolds/sidecontrol/controls/export.tsx @@ -1,54 +1,203 @@ import { saveAs } from "file-saver"; import { Button } from "@/components/ui-editor/button"; import { toast } from "sonner"; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuLabel, -} from "@/components/ui/dropdown-menu"; -import { useDialogState } from "@/components/hooks/use-dialog-state"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, - DialogClose, -} from "@/components/ui/dialog"; import React from "react"; +import { io } from "@grida/io"; +import { Input } from "@/components/ui/input"; +import InputPropertyNumber from "../ui/number"; +import { WorkbenchUI } from "@/components/workbench"; +import { cn } from "@/components/lib/utils"; +import { Spinner } from "@/components/ui/spinner"; import { Select, SelectContent, SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { CodeIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons"; +} from "@/components/ui-editor/select"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { useCurrentEditor, useNodeMetadata } from "@/grida-canvas-react"; +import { editor as editorTypes } from "@/grida-canvas"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { Input } from "@/components/ui/input"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import Link from "next/link"; -import { Badge } from "@/components/ui/badge"; -import { exportWithP666 } from "@/grida-canvas-plugin-p666"; -import { exportAsImage } from "@/grida-canvas/backends/dom-export"; -import { useCurrentEditor } from "@/grida-canvas-react"; - -const mimes = { - PNG: "image/png", - JPEG: "image/jpeg", - PDF: "application/pdf", - SVG: "image/svg+xml", -} as const; + PlusIcon, + MinusIcon, + DotsVerticalIcon, + ChevronDownIcon, +} from "@radix-ui/react-icons"; +import type grida from "@grida/schema"; +import { + SidebarSection, + SidebarSectionHeaderItem, + SidebarSectionHeaderLabel, + SidebarSectionHeaderActions, + SidebarMenuSectionContent, +} from "@/components/sidebar"; +import { PropertyLine, PropertyEnum } from "../ui"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; + +/** + * Available scale presets for auto-assignment when adding new export configs + */ +const AUTO_SCALE_PRESETS = [1, 2, 3] as const; + +/** + * Generates auto suffix for a given scale (e.g., "@2x" for scale 2). + * Returns undefined for scale 1 (no suffix needed). + */ +function getAutoSuffix(scale: number): string | undefined { + if (scale === 1) { + return undefined; + } + return `@${scale}x`; +} + +/** + * Checks if a suffix matches the auto-generated pattern for a given scale. + * This is used to determine if the suffix should be updated when scale changes. + */ +function isAutoSuffix(suffix: string | undefined, scale: number): boolean { + if (!suffix) { + return scale === 1; + } + return suffix === getAutoSuffix(scale); +} + +/** + * Helper to convert NodeExportSettings to ExportConfigOf + * Used by export handlers to transform stored metadata into runtime export config + */ +function buildExportConfig( + settings: grida.program.document.NodeExportSettings +): editorTypes.api.ExportConfigOf< + grida.program.document.NodeExportSettings["format"] +> { + const format = settings.format; + + // Vector formats (PDF, SVG) - no constraints + if (format === "PDF" || format === "SVG") { + return { format } as editorTypes.api.ExportConfigOf; + } + + // Image formats - require constraints + const imageSettings = + settings as grida.program.document.NodeExportSettings_Image; + const constraints = + imageSettings.constraints && + (imageSettings.constraints.type === "scale" || + imageSettings.constraints.type === "scale-to-fit-width" || + imageSettings.constraints.type === "scale-to-fit-height") + ? { + type: imageSettings.constraints.type, + value: imageSettings.constraints.value, + } + : { type: "scale" as const, value: 1 }; + + if (format === "PNG" || format === "BMP") { + return { + format, + constraints, + } as editorTypes.api.ExportConfigOf; + } + + // JPEG and WEBP support quality + return { + format, + constraints, + quality: imageSettings.quality, + } as editorTypes.api.ExportConfigOf; +} + +/** + * Finds the next available scale from presets [1, 2, 3] that isn't already in use. + * If all presets are used, cycles back to 1. + */ +function getNextAvailableScale( + existingConfigs: grida.program.document.NodeExportSettings[] +): number { + // Extract scales from existing configs + const usedScales = new Set(); + for (const config of existingConfigs) { + // Only image configs have constraints + if ( + config.format === "PNG" || + config.format === "JPEG" || + config.format === "WEBP" || + config.format === "BMP" + ) { + if ( + config.constraints?.type === "scale" && + config.constraints.value !== undefined + ) { + usedScales.add(config.constraints.value); + } + } + } + + // Find first unused scale from presets + for (const preset of AUTO_SCALE_PRESETS) { + if (!usedScales.has(preset)) { + return preset; + } + } + + // All presets are used, cycle back to first + return AUTO_SCALE_PRESETS[0]; +} + +export function ExportSection({ + node_id, + name, +}: { + node_id: string; + name: string; +}) { + const editor = useCurrentEditor(); + + // Subscribe to export_settings from metadata + const nodeMetadata = useNodeMetadata(node_id, "export_settings"); + + // Get export configs - this will update when metadata changes + const exportConfigs = React.useMemo(() => { + return editor.getExportConfigs(node_id); + }, [editor, node_id, nodeMetadata]); + + const hasConfigs = exportConfigs.length > 0; + + const onAddConfig = () => { + const nextScale = getNextAvailableScale(exportConfigs); + const autoSuffix = getAutoSuffix(nextScale); + const newConfig: grida.program.document.NodeExportSettings_Image = { + format: "PNG", + constraints: { type: "scale", value: nextScale }, + ...(autoSuffix !== undefined && { suffix: autoSuffix }), + }; + editor.addExportConfig(node_id, newConfig); + }; + + return ( + + + Export + + + + + {hasConfigs && ( + + + + )} + + ); +} export function ExportNodeControl({ node_id, @@ -60,359 +209,641 @@ export function ExportNodeControl({ disabled?: boolean; }) { const editor = useCurrentEditor(); - const advancedExportDialog = useDialogState("advenced-export", { - refreshkey: true, - }); - - const exportHandler = ( - dataPromise: Promise, - format: "SVG" | "PDF" | "PNG" | "JPEG" - ): Promise => { - return new Promise(async (resolve, reject) => { - try { - const data = await dataPromise; - - if (!data) { - reject(new Error("Failed to export")); + const [isExporting, setIsExporting] = React.useState(false); + + // Subscribe to export_settings from metadata + const nodeMetadata = useNodeMetadata(node_id, "export_settings"); + + // Get export configs - this will update when metadata changes + const exportConfigs = React.useMemo(() => { + return editor.getExportConfigs(node_id); + }, [editor, node_id, nodeMetadata]); + + const onExportAll = async () => { + // Show spinner immediately + setIsExporting(true); + + // Small delay to show spinner before starting expensive export + await new Promise((resolve) => setTimeout(resolve, 50)); + + try { + if (exportConfigs.length === 1) { + // Single file: download as-is + const config = exportConfigs[0]; + if (!config?.format) return; + + const format = config.format; + if ( + !editorTypes.internal.export_settings.ALL_FORMATS.includes(format) + ) { return; } - const blob = new Blob([data as BlobPart], { type: mimes[format] }); - resolve(blob); - } catch (e) { - reject(e); + // Build export config from stored settings + const exportConfig = buildExportConfig(config); + const editorApi: editorTypes.api.IDocumentExportPluginActions = editor; + const data = await editorApi.exportNodeAs( + node_id, + format, + exportConfig + ); + + const blob = new Blob( + [typeof data === "string" ? data : (data as BlobPart)], + { type: editorTypes.internal.export_settings.getMimeType(format) } + ); + const suffix = config.suffix ? `-${config.suffix}` : ""; + saveAs( + blob, + `${name}${suffix}.${editorTypes.internal.export_settings.getFileExtension(format)}` + ); + return; } - }); - }; - const onExport = async (format: "SVG" | "PDF" | "PNG" | "JPEG") => { - let task: Promise; - - switch (format) { - case "JPEG": - case "PNG": - task = exportHandler(editor.exportNodeAs(node_id, format), format); - break; - case "PDF": - task = exportHandler(editor.exportNodeAs(node_id, format), format); - break; - case "SVG": - task = exportHandler(editor.exportNodeAs(node_id, format), format); - break; - } + // Multiple files: create zip + const files: Record = {}; - if (task) { - toast.promise(task, { - loading: "Exporting...", - success: "Exported", - error: "Failed to export", - }); + const tasks = exportConfigs.map(async (config, index) => { + if (!config?.format) return null; + + const format = config.format; + if ( + !editorTypes.internal.export_settings.ALL_FORMATS.includes(format) + ) { + return null; + } - task.then((blob) => { - saveAs(blob, `${name}.${format.toLowerCase()}`); + // Build export config from stored settings + const exportConfig = buildExportConfig(config); + const editorApi: editorTypes.api.IDocumentExportPluginActions = editor; + const data = await editorApi.exportNodeAs( + node_id, + format, + exportConfig + ); + + const suffix = config.suffix ? `-${config.suffix}` : ""; + const filename = `${name}${suffix}.${editorTypes.internal.export_settings.getFileExtension(format)}`; + + // Convert to Uint8Array for zip + const bytes = io.zip.ensureUint8Array(data); + + files[filename] = bytes; + return { filename, data }; }); - } else { - toast.error("Export is not supported yet"); + + await toast.promise( + Promise.all(tasks).then(() => { + // Create zip file + const zipData = io.zip.create(files); + const zipBlob = new Blob([zipData as BlobPart], { + type: "application/zip", + }); + saveAs(zipBlob, `${name}.zip`); + }), + { + loading: `Exporting ${exportConfigs.length} file(s)...`, + success: `Exported ${exportConfigs.length} file(s) as ZIP`, + error: "Failed to export some files", + } + ); + } finally { + setIsExporting(false); } }; + const onRemoveConfig = (index: number) => { + editor.removeExportConfig(node_id, index); + }; + + const onUpdateConfig = ( + index: number, + updates: Partial + ) => { + const current = exportConfigs[index]; + if (!current) return; + + editor.updateExportConfig(node_id, index, { + ...current, + ...updates, + }); + }; + return ( <> - {editor.backend === "dom" && ( - ( + onRemoveConfig(index)} + onUpdate={(updates) => onUpdateConfig(index, updates)} + disabled={disabled} /> - )} - - + ))} + {exportConfigs.length >= 1 && ( + - - - {editor.backend === "dom" && ( - <> - - - BETA - -
-
- - "Export as" is currently in beta and may produce - inconsistent outputs. - -
-
- - - )} - - onExport("PNG")} - > - PNG - - onExport("JPEG")} - > - JPEG - - onExport("SVG")} - > - SVG - - onExport("PDF")} - > - PDF - - {editor.backend === "dom" && ( - <> - - - - Advanced - - - )} -
-
+ + )} ); } -function AdvancedExportDialog({ - node_id, - defaultName, - ...props -}: React.ComponentProps & { - node_id: string; - defaultName: string; +function ExportConfigRow({ + config, + onRemove, + onUpdate, + disabled, +}: { + config: grida.program.document.NodeExportSettings; + onRemove: () => void; + onUpdate: ( + updates: Partial + ) => void; + disabled?: boolean; }) { - const [backend, setBackend] = React.useState<"canvas" | "p666">("canvas"); - const [format, setFormat] = React.useState<"PNG" | "SVG" | "JPEG" | "PDF">( - "PNG" - ); - const [name, setName] = React.useState(defaultName); - const [xpath, setXPath] = React.useState(""); + const format: editorTypes.internal.export_settings.Format = (config.format || + "PNG") as editorTypes.internal.export_settings.Format; - const options = { - canvas: ["PNG", "SVG", "JPEG"], - p666: ["PNG", "PDF"], + // Extract scale value - only image configs have constraints + const scaleValue = + (config.format === "PNG" || + config.format === "JPEG" || + config.format === "WEBP" || + config.format === "BMP") && + (config as grida.program.document.NodeExportSettings_Image).constraints + ?.type === "scale" + ? (config as grida.program.document.NodeExportSettings_Image).constraints! + .value + : 1; + + // Scale is not supported for vector formats (SVG) and PDF + const scaleSupported = + editorTypes.internal.export_settings.supportsScale(format); + + const handleFormatChange = (newFormat: string) => { + const validFormat = editorTypes.internal.export_settings.ALL_FORMATS.find( + (f) => f === newFormat + ); + if (validFormat) { + onUpdate({ format: validFormat }); + } }; - const onExport = async () => { - switch (format) { - case "PNG": - case "JPEG": - case "SVG": - { - const result = await exportAsImage(node_id, format); - if (!result) { - toast.error("Failed to export"); - return; - } - await fetch(result.url) - .then((res) => res.blob()) - .then((blob) => { - saveAs(blob, `${name}.${format}`); - }); + const handleScaleChange = (newScale: string) => { + const oldScale = scaleValue; + const scale = parseFloat(newScale) || 1; + + // If suffix matches the auto pattern for old scale, update it for new scale + const currentSuffix = config.suffix; + const shouldUpdateSuffix = isAutoSuffix(currentSuffix, oldScale); + + // Only image configs can have constraints + if ( + config.format === "PNG" || + config.format === "JPEG" || + config.format === "WEBP" || + config.format === "BMP" + ) { + const updates: { + constraints: { type: "scale"; value: number }; + suffix?: string; + } = { + constraints: { type: "scale", value: scale }, + }; + + if (shouldUpdateSuffix) { + const newSuffix = getAutoSuffix(scale); + if (newSuffix !== undefined) { + updates.suffix = newSuffix; } - break; - case "PDF": - const task = exportWithP666(node_id, format).then((blob) => { - saveAs(blob, `${name}.${format}`); - }); + // If scale is 1, we omit suffix (don't set it to undefined) + } - toast.promise(task, { - loading: "Exporting...", - success: "Exported", - error: "Failed to export", - }); - break; + onUpdate(updates as Partial); } }; + const scalePresets = [0.5, 0.75, 1, 1.5, 2, 3, 4]; + const hasPreset = scalePresets.includes(scaleValue); + const isDisabled = disabled || !scaleSupported; + return ( - - - - Advanced Export - - Export node with advanced options - - -
-
-
- - v && setBackend(v as any)} - className="w-min" - > - Canvas - Daemon - - {backend === "p666" && ( -
- To use daemon, run{" "} - - p666 - {" "} - on your machine -
- {">"} npx p666 -
+ + +
+
-
- - setName(e.target.value)} + > + { + if (v !== undefined) { + handleScaleChange(v.toString()); + } + }} + aria-label="Export scale" /> +
+ +
-
- - -
+ f !== "BMP" + )} + className="flex-1" + /> + + + +
-
+ + + + ); +} + +function ExportConfigPopoverContent({ + config, + onUpdate, + disabled, +}: { + config: grida.program.document.NodeExportSettings; + onUpdate: ( + updates: Partial + ) => void; + disabled?: boolean; +}) { + const format = config.format || "PNG"; + const isImageFormat = + format === "PNG" || + format === "JPEG" || + format === "WEBP" || + format === "BMP"; + const imageConfig = isImageFormat + ? (config as grida.program.document.NodeExportSettings_Image) + : null; + + const [suffix, setSuffix] = React.useState(config.suffix || ""); + const [constraintType, setConstraintType] = React.useState< + grida.program.document.NodeExportSettingsConstraints["type"] + >(imageConfig?.constraints?.type || "scale"); + const [constraintValue, setConstraintValue] = React.useState( + imageConfig?.constraints?.value?.toString() || "1" + ); + const [quality, setQuality] = React.useState( + imageConfig?.quality + ); + + React.useEffect(() => { + setSuffix(config.suffix || ""); + if (imageConfig) { + setConstraintType(imageConfig.constraints?.type || "scale"); + setConstraintValue(imageConfig.constraints?.value?.toString() || "1"); + setQuality(imageConfig.quality); + } + }, [config, imageConfig]); + + const handleSuffixChange = (value: string) => { + setSuffix(value); + onUpdate({ suffix: value || undefined }); + }; + + const handleConstraintChange = ( + type: grida.program.document.NodeExportSettingsConstraints["type"], + value?: number + ) => { + setConstraintType(type); + if (value !== undefined) { + setConstraintValue(value.toString()); + } + // Only image configs can have constraints + if (isImageFormat) { + // Note: According to the type, NONE still requires a value + const constraintValueNum = value || parseFloat(constraintValue) || 1; + onUpdate({ + constraints: { type, value: constraintValueNum }, + } as Partial); + } + }; + + const handleQualityChange = (value: string) => { + const qualityMap: Record = { + High: 90, + Medium: 75, + Low: 50, + }; + const qualityValue = qualityMap[value]; + if (qualityValue !== undefined && isImageFormat) { + setQuality(qualityValue); + onUpdate({ + quality: qualityValue, + } as Partial); + } + }; + + const getQualityLabel = (q: number | undefined): string | undefined => { + if (q === undefined) return undefined; + if (q === 90) return "High"; + if (q === 75) return "Medium"; + if (q === 50) return "Low"; + return undefined; + }; + + return ( + +
+
+ + handleSuffixChange(e.target.value)} + disabled={disabled} + className={WorkbenchUI.inputVariants({ size: "xs" })} + /> +
+ {editorTypes.internal.export_settings.supportsQuality(format) && (
- - - - - - Learn More About Grida XPath - - - - - - - -