From 166eb285981f6c8b238c067846fa0931b52e7c6e Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 27 Dec 2025 20:58:25 +0900 Subject: [PATCH 01/10] feat: add quality settings for JPEG and WEBP exports to enhance image compression control --- crates/grida-canvas-wasm/lib/index.ts | 32 +++++++++++++++++-- .../src/export/export_as_image.rs | 9 +++++- crates/grida-canvas/src/export/types.rs | 11 +++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/grida-canvas-wasm/lib/index.ts b/crates/grida-canvas-wasm/lib/index.ts index 6ba7ba2563..95580ad446 100644 --- a/crates/grida-canvas-wasm/lib/index.ts +++ b/crates/grida-canvas-wasm/lib/index.ts @@ -46,10 +46,38 @@ export namespace types { 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/src/export/export_as_image.rs b/crates/grida-canvas/src/export/export_as_image.rs index 6ef921b2ec..c59d103ae8 100644 --- a/crates/grida-canvas/src/export/export_as_image.rs +++ b/crates/grida-canvas/src/export/export_as_image.rs @@ -49,7 +49,14 @@ pub fn export_node_as_image( r.load_scene(scene.clone()); let image = r.snapshot(); - let Some(data) = image.encode(None, skfmt, None) else { + // 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 { r.free(); return None; }; diff --git a/crates/grida-canvas/src/export/types.rs b/crates/grida-canvas/src/export/types.rs index 8ae4de07fd..7d18d5e024 100644 --- a/crates/grida-canvas/src/export/types.rs +++ b/crates/grida-canvas/src/export/types.rs @@ -29,11 +29,15 @@ impl Default for ExportAsPNG { #[derive(Clone, Deserialize)] pub struct ExportAsJPEG { pub(crate) constraints: ExportConstraints, + #[serde(default)] + pub(crate) quality: Option, // 0-100, None means use Skia default } #[derive(Clone, Deserialize)] pub struct ExportAsWEBP { pub(crate) constraints: ExportConstraints, + #[serde(default)] + pub(crate) quality: Option, // 0-100, None means use Skia default } #[derive(Clone, Deserialize)] @@ -106,8 +110,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 { From 78cd28ee7fe38453f5510bcd9d559e1ec1a208c5 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 28 Dec 2025 16:33:01 +0900 Subject: [PATCH 02/10] feat: implement metadata management for nodes, including export settings and associated actions --- editor/grida-canvas/action.ts | 18 +- editor/grida-canvas/backends/dom-export.ts | 39 +- editor/grida-canvas/backends/noop.ts | 8 +- editor/grida-canvas/backends/wasm.ts | 71 +- editor/grida-canvas/editor.i.ts | 275 +++- editor/grida-canvas/editor.ts | 153 ++- editor/grida-canvas/plugins/yjs/y-document.ts | 1 + .../grida-canvas/reducers/document.reducer.ts | 17 +- .../grida-canvas/reducers/metadata.reducer.ts | 33 + .../scaffolds/sidecontrol/controls/export.tsx | 1135 ++++++++++++----- .../sidecontrol-node-selection.tsx | 32 +- packages/grida-canvas-io/index.ts | 1 + packages/grida-canvas-schema/grida.ts | 67 +- 13 files changed, 1407 insertions(+), 443 deletions(-) create mode 100644 editor/grida-canvas/reducers/metadata.reducer.ts diff --git a/editor/grida-canvas/action.ts b/editor/grida-canvas/action.ts index 49933a7f4e..01a4ce5ed2 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,17 @@ 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/remove"; + node_id: string; + namespace: "export_settings"; + } + | { 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..32b414e669 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, @@ -54,22 +63,35 @@ export class CanvasWasmDefaultExportInterfaceProvider 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 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; + if (format === "PNG") { + exportFormat = { format: "PNG", constraints }; + } else if (format === "JPEG") { + exportFormat = { format: "JPEG", constraints, quality: config?.quality }; + } else if (format === "WEBP") { + exportFormat = { format: "WEBP", constraints, quality: config?.quality }; + } else { + // BMP + exportFormat = { format: "BMP", constraints }; + } + + const data = await this.surface.exportNodeAs(node_id, exportFormat); return data.data; } @@ -88,23 +110,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..e0083e1ce5 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; } /** @@ -3987,12 +4014,113 @@ 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, currently only `export_settings` is implemented. + * + * @template NS - The namespace type (currently only "export_settings") + * @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 + : 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[] + : never + ): void; + + /** + * Remove metadata for a node by namespace + */ + removeNodeMetadata( + node_id: grida.program.nodes.NodeID, + namespace: "export_settings" + ): 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; + } + + /** + * 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 +4135,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 supported export formats 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..f9a2add0b7 100644 --- a/editor/grida-canvas/editor.ts +++ b/editor/grida-canvas/editor.ts @@ -2563,7 +2563,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 +3650,142 @@ export class Editor // ============================================================== // #region IExportPluginActions implementation // ============================================================== - exportNodeAs( + exportNodeAs( node_id: string, - format: "PNG" | "JPEG" - ): Promise; - exportNodeAs(node_id: string, format: "PDF"): Promise; - exportNodeAs(node_id: string, format: "SVG"): Promise; - async 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 + : never { + if (namespace === "export_settings") { + const result = + this.doc.state.document.metadata?.[node_id]?.export_settings; + return result as NS extends "export_settings" + ? grida.program.document.NodeExportSettings[] | undefined + : never; + } + return undefined as never; + } + + setNodeMetadata( + node_id: string, + namespace: NS, + data: NS extends "export_settings" + ? grida.program.document.NodeExportSettings[] + : 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[], + }); + } + } + + removeNodeMetadata(node_id: string, namespace: "export_settings"): void { + this.doc.dispatch({ + type: "node-metadata/remove", + node_id, + namespace: "export_settings", + }); + } + + 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"); + } + // ============================================================== + // #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 +4507,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 +4526,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/scaffolds/sidecontrol/controls/export.tsx b/editor/scaffolds/sidecontrol/controls/export.tsx index bba484d000..ccf627065a 100644 --- a/editor/scaffolds/sidecontrol/controls/export.tsx +++ b/editor/scaffolds/sidecontrol/controls/export.tsx @@ -1,54 +1,206 @@ 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 { zipSync } from "fflate"; +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, useEditorState } 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 === "WIDTH" || + imageSettings.constraints.type === "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 metadata changes for this specific node + const nodeMetadata = useEditorState( + editor, + (state) => state.document.metadata?.[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 +212,638 @@ 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 metadata changes for this specific node + const nodeMetadata = useEditorState( + editor, + (state) => state.document.metadata?.[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; + } + + // 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)}`; - task.then((blob) => { - saveAs(blob, `${name}.${format.toLowerCase()}`); + // Convert to Uint8Array for zip + let bytes: Uint8Array; + if (typeof data === "string") { + bytes = new TextEncoder().encode(data); + } else { + // data is Uint8Array | false, but we already checked !data above + bytes = data as Uint8Array; + } + + 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 = zipSync(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; + + // 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; - const options = { - canvas: ["PNG", "SVG", "JPEG"], - p666: ["PNG", "PDF"], + // 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); + 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" /> +
+ +
-
- - -
+ + + + + +
+ +
+
+ ); +} + +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< + "NONE" | "SCALE" | "WIDTH" | "HEIGHT" + >(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: "NONE" | "SCALE" | "WIDTH" | "HEIGHT", + 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 - - - - - - - -