Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm
Git LFS file not shown
45 changes: 42 additions & 3 deletions crates/grida-canvas-wasm/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/grida-canvas-wasm/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
32 changes: 25 additions & 7 deletions crates/grida-canvas/src/export/export_as_image.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::node::schema::Size;
Comment thread
softmarshmallow marked this conversation as resolved.
use crate::{
export::{ExportAsImage, ExportSize, Exported},
node::schema::Scene,
Expand Down Expand Up @@ -32,9 +33,19 @@ pub fn export_node_as_image(
) -> Option<Exported> {
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),
Expand All @@ -47,22 +58,29 @@ 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())),
ExportAsImage::WEBP(_) => Some(Exported::WEBP(data.to_vec())),
ExportAsImage::BMP(_) => Some(Exported::BMP(data.to_vec())),
};

r.free();

exported
}
3 changes: 3 additions & 0 deletions crates/grida-canvas/src/export/export_as_pdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::{
runtime::{
camera::Camera2D,
font_repository::FontRepository,
image_repository::ImageRepository,
scene::{Backend, Renderer, RendererOptions},
},
};
Expand All @@ -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<Exported> {
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions crates/grida-canvas/src/export/export_as_svg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::{
runtime::{
camera::Camera2D,
font_repository::FontRepository,
image_repository::ImageRepository,
scene::{Backend, Renderer, RendererOptions},
},
};
Expand All @@ -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<Exported> {
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions crates/grida-canvas/src/export/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 17 additions & 6 deletions crates/grida-canvas/src/export/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand All @@ -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<u32>,
}

#[derive(Clone, Deserialize)]
pub struct ExportAsWEBP {
pub(crate) constraints: ExportConstraints,

/// 0-100, None means use Skia default (75)
#[serde(default)]
pub(crate) quality: Option<u32>,
}

#[derive(Clone, Deserialize)]
Expand Down Expand Up @@ -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<u32>) -> Self {
Self::JPEG(ExportAsJPEG {
constraints,
quality,
})
}

pub fn pdf() -> Self {
Expand Down
1 change: 1 addition & 0 deletions editor/grida-canvas-react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
useNode,
useBrushState,
useComputedNode,
useNodeMetadata,
useNodeActions,
useTransformState,
useToolState,
Expand Down
23 changes: 22 additions & 1 deletion editor/grida-canvas-react/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null),
name: (name: string) => {
node.name = name;
},
Expand Down Expand Up @@ -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<NS extends "export_settings" | "userdata">(
node_id: string,
namespace: NS
): NS extends "export_settings"
? grida.program.document.NodeExportSettings[] | undefined
: NS extends "userdata"
? Record<string, unknown> | null | undefined
: never {
const editor = useCurrentEditor();
return useEditorState(
editor,
(state) => state.document.metadata?.[node_id]?.[namespace]
) as any;
}
2 changes: 1 addition & 1 deletion editor/grida-canvas-react/use-mixed-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
),
Expand Down
24 changes: 23 additions & 1 deletion editor/grida-canvas/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ export type DocumentAction =
| TemplateNodeOverrideChangeAction
| TemplateEditorSetTemplatePropsAction
//
| SchemaAction;
| SchemaAction
//
| MetadataAction;

type NodeID = string & {};
type Vector2 = [number, number];
Expand Down Expand Up @@ -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<string, unknown> | null;
}
| {
type: "node-metadata/remove";
node_id: string;
namespace: "export_settings" | "userdata";
}
| { type: "node-metadata/remove-all"; node_id: string };
Loading