Files
OpenMAIC/lib/api/stage-api-element.ts
T
wyuc 7cb129183d chore(packages): publish the @openmaic/* SDK family to npm (#778) (#780)
* chore(packages): publish the @openmaic/* SDK family to npm (#778)

Prepares the @openmaic/{dsl,renderer,importer} family for its first npm
publish, and moves the SDK packages onto the @openmaic scope.

Why the scope move: the @maic org name is unavailable on npm (an unscoped
`maic` package already holds the name), so @maic/* is not claimable. @openmaic
matches the project name, the scope is free, and the repo already ships an
@openmaic/docs package — so the SDK family now lines up with that convention.

- rename @maic/{dsl,renderer,importer} -> @openmaic/* across packages, the
  workspace glob, the package dir, and all import sites; lockfile regenerated
- renderer: add publishConfig (public, registry.npmjs.org) — was missing, so
  a scoped publish would default to the wrong registry / restricted access
- importer: add a files allowlist (dist, README, LICENSE) and drop the
  fragile .npmignore blacklist that shipped src; add an exports map so ESM
  consumers resolve dist/index.js instead of falling back to the .cjs main
- all three: add a prepublishOnly build (+ test/typecheck) guard so a publish
  can never ship a stale or empty dist
- add a tag-triggered publish workflow with npm provenance, pinned by name to
  the three @openmaic packages so the vendored forks (mathml2omml, pptxgenjs)
  are never published

Refs #778, #720 (Phase 1).

* fix(packages): address cross-review on the @openmaic publish prep

Cross-review (Claude /code-review + codex) on this PR surfaced:

- renderer's advertised CJS entry was broken: it keeps @openmaic/dsl external
  and imports a runtime enum from it, but dsl is ESM-only (no `require`
  condition), so `require('@openmaic/renderer')` would throw
  ERR_PACKAGE_PATH_NOT_EXPORTED. Make renderer ESM-only: drop the `.cjs`
  rollup output, `main` now points at the ESM build, and the `require`
  conditions are removed from `exports`. (importer is unaffected — it bundles
  dsl, so its CJS build still works.)
- prepublishOnly re-ran the test suite during `pnpm -r publish`, so a flaky
  test after dsl had already published gave a non-atomic partial release.
  Reduce prepublishOnly to a build-only guard (never ship stale/empty dist)
  and move the real test/typecheck gate into the workflow, before any publish.
- document that an @openmaic/* tag publishes the whole family via `pnpm -r`
  (pnpm skips already-published versions); the tag is a release marker, not a
  per-package gate.

Verified: dsl + renderer + importer build; renderer emits ESM only (0 .cjs),
all exports entries resolve; `npm pack` ships dist + README + LICENSE with no
src leak; frozen-lockfile passes.

Refs #778.

* style: reflow @openmaic/dsl type imports past print-width after rename

The @maic -> @openmaic rename lengthened two single-line type imports past
prettier's 100-col width; prettier --check flagged them. Pure formatting.

Refs #778.

* docs(importer): mark @openmaic/importer browser-only (cr-loop accepted limitation)

codex cross-review flagged that the published @openmaic/importer throws
`XMLHttpRequest is not a constructor` when loaded in a pure Node process —
its rollup build is browser-targeted (`nodeResolve({browser:true})` + a
browser pdf.js build). The app only consumes it client-side ('use client'),
so this is by design. Document it as an accepted limitation: prominent
browser-only note in the README and a `browser` field in the manifest.

Refs #778.
2026-06-24 15:12:23 +08:00

400 lines
11 KiB
TypeScript

/**
* Stage API - Element Operations
*
* Factory function that creates the element namespace of the Stage API.
* Handles element CRUD operations for slide-type scenes.
*/
import type { SlideContent } from '@/lib/types/stage';
import type { PPTElement } from '@openmaic/dsl';
import type { StageStore, APIResult, CreateElementParams } from './stage-api-types';
import { generateId, getScene } from './stage-api-defaults';
/**
* Create the element management API
*
* @param store - Zustand store instance
* @returns Element namespace API
*/
export function createElementAPI(store: StageStore) {
return {
/**
* Add an element to a Slide
*
* @param sceneId - Scene ID
* @param element - Element parameters (must include type, left, top, width, height)
* @returns Element ID
*/
add(sceneId: string, element: CreateElementParams): APIResult<string> {
try {
const state = store.getState();
const scene = getScene(state.scenes, sceneId);
if (!scene) {
return { success: false, error: `Scene not found: ${sceneId}` };
}
if (scene.type !== 'slide') {
return { success: false, error: `Scene is not a slide: ${sceneId}` };
}
const content = scene.content as SlideContent;
const elementId = generateId(element.type);
const newElement: PPTElement = {
...element,
id: elementId,
rotate: element.rotate ?? 0,
} as PPTElement;
const newScenes = state.scenes.map((s) => {
if (s.id === sceneId) {
return {
...s,
content: {
...content,
canvas: {
...content.canvas,
elements: [...content.canvas.elements, newElement],
},
},
updatedAt: Date.now(),
};
}
return s;
});
store.setState({ scenes: newScenes });
return { success: true, data: elementId };
} catch (error) {
return { success: false, error: String(error) };
}
},
/**
* Add elements in batch
*
* @deprecated will be removed in the future
* @param sceneId - Scene ID
* @param elements - Element array
* @returns Element ID array
*/
addBatch(sceneId: string, elements: CreateElementParams[]): APIResult<string[]> {
try {
const state = store.getState();
const scene = getScene(state.scenes, sceneId);
if (!scene) {
return { success: false, error: `Scene not found: ${sceneId}` };
}
if (scene.type !== 'slide') {
return { success: false, error: `Scene is not a slide: ${sceneId}` };
}
const content = scene.content as SlideContent;
const elementIds: string[] = [];
const newElements: PPTElement[] = elements.map((el) => {
const elementId = generateId(el.type);
elementIds.push(elementId);
return {
...el,
id: elementId,
rotate: el.rotate ?? 0,
} as PPTElement;
});
const newScenes = state.scenes.map((s) => {
if (s.id === sceneId) {
return {
...s,
content: {
...content,
canvas: {
...content.canvas,
elements: [...content.canvas.elements, ...newElements],
},
},
updatedAt: Date.now(),
};
}
return s;
});
store.setState({ scenes: newScenes });
return { success: true, data: elementIds };
} catch (error) {
return { success: false, error: String(error) };
}
},
/**
* Delete an element
*
* @param sceneId - Scene ID
* @param elementId - Element ID
* @returns Whether successful
*/
delete(sceneId: string, elementId: string): APIResult<boolean> {
try {
const state = store.getState();
const scene = getScene(state.scenes, sceneId);
if (!scene) {
return { success: false, error: `Scene not found: ${sceneId}` };
}
if (scene.type !== 'slide') {
return { success: false, error: `Scene is not a slide: ${sceneId}` };
}
const content = scene.content as SlideContent;
const newScenes = state.scenes.map((s) => {
if (s.id === sceneId) {
return {
...s,
content: {
...content,
canvas: {
...content.canvas,
elements: content.canvas.elements.filter((el) => el.id !== elementId),
},
},
updatedAt: Date.now(),
};
}
return s;
});
store.setState({ scenes: newScenes });
return { success: true, data: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
/**
* Delete elements in batch
*
* @deprecated will be removed in the future
* @param sceneId - Scene ID
* @param elementIds - Element ID array
* @returns Whether successful
*/
deleteBatch(sceneId: string, elementIds: string[]): APIResult<boolean> {
try {
const state = store.getState();
const scene = getScene(state.scenes, sceneId);
if (!scene) {
return { success: false, error: `Scene not found: ${sceneId}` };
}
if (scene.type !== 'slide') {
return { success: false, error: `Scene is not a slide: ${sceneId}` };
}
const content = scene.content as SlideContent;
const elementIdSet = new Set(elementIds);
const newScenes = state.scenes.map((s) => {
if (s.id === sceneId) {
return {
...s,
content: {
...content,
canvas: {
...content.canvas,
elements: content.canvas.elements.filter((el) => !elementIdSet.has(el.id)),
},
},
updatedAt: Date.now(),
};
}
return s;
});
store.setState({ scenes: newScenes });
return { success: true, data: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
/**
* Update an element
*
* @param sceneId - Scene ID
* @param elementId - Element ID
* @param updates - Properties to update
* @returns Whether successful
*/
update(sceneId: string, elementId: string, updates: Partial<PPTElement>): APIResult<boolean> {
try {
const state = store.getState();
const scene = getScene(state.scenes, sceneId);
if (!scene) {
return { success: false, error: `Scene not found: ${sceneId}` };
}
if (scene.type !== 'slide') {
return { success: false, error: `Scene is not a slide: ${sceneId}` };
}
const content = scene.content as SlideContent;
const newScenes = state.scenes.map((s) => {
if (s.id === sceneId) {
return {
...s,
content: {
...content,
canvas: {
...content.canvas,
elements: content.canvas.elements.map((el) =>
el.id === elementId ? { ...el, ...updates } : el,
),
},
},
updatedAt: Date.now(),
};
}
return s;
});
store.setState({ scenes: newScenes });
return { success: true, data: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
/**
* Get an element
*
* @param sceneId - Scene ID
* @param elementId - Element ID
* @returns Element object
*/
get(sceneId: string, elementId: string): APIResult<PPTElement> {
try {
const state = store.getState();
const scene = getScene(state.scenes, sceneId);
if (!scene) {
return { success: false, error: `Scene not found: ${sceneId}` };
}
if (scene.type !== 'slide') {
return { success: false, error: `Scene is not a slide: ${sceneId}` };
}
const content = scene.content as SlideContent;
const element = content.canvas.elements.find((el) => el.id === elementId);
if (!element) {
return { success: false, error: `Element not found: ${elementId}` };
}
return { success: true, data: element };
} catch (error) {
return { success: false, error: String(error) };
}
},
/**
* Get all elements of a scene
*
* @param sceneId - Scene ID
* @returns Element list
*/
list(sceneId: string): APIResult<PPTElement[]> {
try {
const state = store.getState();
const scene = getScene(state.scenes, sceneId);
if (!scene) {
return { success: false, error: `Scene not found: ${sceneId}` };
}
if (scene.type !== 'slide') {
return { success: false, error: `Scene is not a slide: ${sceneId}` };
}
const content = scene.content as SlideContent;
return { success: true, data: [...content.canvas.elements] };
} catch (error) {
return { success: false, error: String(error) };
}
},
/**
* Move an element (relative movement)
*
* @deprecated will be removed in the future
* @param sceneId - Scene ID
* @param elementId - Element ID
* @param deltaX - X-axis movement distance
* @param deltaY - Y-axis movement distance
* @returns Whether successful
*/
move(sceneId: string, elementId: string, deltaX: number, deltaY: number): APIResult<boolean> {
try {
const state = store.getState();
const scene = getScene(state.scenes, sceneId);
if (!scene) {
return { success: false, error: `Scene not found: ${sceneId}` };
}
if (scene.type !== 'slide') {
return { success: false, error: `Scene is not a slide: ${sceneId}` };
}
const content = scene.content as SlideContent;
const newScenes = state.scenes.map((s) => {
if (s.id === sceneId) {
return {
...s,
content: {
...content,
canvas: {
...content.canvas,
elements: content.canvas.elements.map((el) => {
if (el.id === elementId) {
return {
...el,
left: el.left + deltaX,
top: el.top + deltaY,
};
}
return el;
}),
},
},
updatedAt: Date.now(),
};
}
return s;
});
store.setState({ scenes: newScenes });
return { success: true, data: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
};
}