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
2 changes: 2 additions & 0 deletions apps/editor/src/services/importing/pptx/pptx-parser-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export type PptxSlideObject =
};

export interface PptxSlide {
backgroundAssetPath?: string;
backgroundColor: string;
id: string;
layoutId?: string;
Expand All @@ -125,6 +126,7 @@ export interface PptxSlide {
}

export interface PptxLayout {
backgroundAssetPath?: string;
backgroundColor: string;
id: string;
name: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function getHexColor(element: ParentNode | undefined, fallback: string, theme?:
}

function toUnitInterval(value: string | null | undefined) {
if (value === null || value === undefined || value.trim() === '') return undefined;
const numericValue = Number(value);
return Number.isFinite(numericValue)
? Math.max(0, Math.min(1, numericValue / 100000))
Expand Down
18 changes: 18 additions & 0 deletions apps/editor/src/services/importing/pptx/pptxParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,19 @@ function getBackgroundColor(document: Document, theme: ParseScope['theme'], fall
return pptxVisualStyle.getHexColor(background, fallback, theme);
}

function getBackgroundAssetPath(
document: Document,
relationships: Map<string, PptxRelationship>,
) {
const background = pptxXml.firstDescendant(document, 'bgPr');
const blip = background ? pptxXml.firstDescendant(background, 'blip') : undefined;
const relationshipId = pptxXml.getRelationshipAttr(blip, 'embed');
if (!relationshipId) return undefined;
const relationship = relationships.get(relationshipId);
if (!relationship || relationship.targetMode !== 'Internal') return undefined;
return relationship.target;
}

function findRelationshipByType(relationships: Map<string, PptxRelationship>, typeSuffix: string) {
return Array.from(relationships.values()).find((relationship) =>
relationship.type.endsWith(typeSuffix),
Expand Down Expand Up @@ -844,7 +857,9 @@ async function parseLayout(
...object,
zIndex: index,
}));
const backgroundAssetPath = getBackgroundAssetPath(document, relationships);
return {
...(backgroundAssetPath ? { backgroundAssetPath } : {}),
backgroundColor: getBackgroundColor(document, theme, '#FFFFFF'),
id: layoutId,
name: getLayoutNameFromDocument(document, layoutPath),
Expand Down Expand Up @@ -902,7 +917,10 @@ async function parseSlide(
if (object.kind === 'video' && startTrigger) object.startTrigger = startTrigger;
}
const resolvedLayoutId = parsedLayout?.id ?? layoutId;
const backgroundAssetPath =
getBackgroundAssetPath(document, rels) ?? parsedLayout?.backgroundAssetPath;
return {
...(backgroundAssetPath ? { backgroundAssetPath } : {}),
backgroundColor: getBackgroundColor(document, theme, parsedLayout?.backgroundColor ?? '#000000'),
id: slideId,
...(resolvedLayoutId ? { layoutId: resolvedLayoutId } : {}),
Expand Down
47 changes: 43 additions & 4 deletions apps/editor/src/services/importing/pptx/pptxProjectMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ function mapObject(
pageId: string,
pageWidth: number,
pageHeight: number,
backgroundColor: string,
backgroundColor: string | undefined,
layoutId?: string,
): DesignElement | undefined {
if (object.kind === 'text') {
Expand All @@ -348,7 +348,7 @@ function mapObject(
...(object.placeholderRole ? { placeholderRole: object.placeholderRole } : {}),
importSource: getImportSource(object, pageId, layoutId),
...style,
fill: getReadableTextFill(style.fill, backgroundColor),
fill: backgroundColor ? getReadableTextFill(style.fill, backgroundColor) : style.fill,
fontSize,
};
}
Expand Down Expand Up @@ -423,6 +423,32 @@ const defaultPlaceholderVisibility: Record<PlaceholderRole, boolean> = {
title: true,
};

function mapBackgroundImage(
backgroundAssetPath: string | undefined,
pptxPackage: PptxPackage,
assets: Record<string, Asset>,
pageId: string,
pageWidth: number,
pageHeight: number,
): DesignElement | undefined {
if (!backgroundAssetPath) return undefined;
const asset = getOrCreateAsset(backgroundAssetPath, pptxPackage, assets);
if (!asset || asset.type !== 'image') return undefined;
return {
id: `${pageId}-background-image`,
type: 'image',
assetId: asset.id,
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
rotation: 0,
locked: false,
visible: true,
opacity: 1,
};
}

function createLayout(
layout: PptxLayout,
pptxPackage: PptxPackage,
Expand All @@ -442,7 +468,7 @@ function createLayout(
layout.id,
pageWidth,
pageHeight,
layout.backgroundColor,
layout.backgroundAssetPath ? undefined : layout.backgroundColor,
layout.id,
);
if (!element) continue;
Expand Down Expand Up @@ -472,6 +498,7 @@ function createSlideFallbackLayout(
return createLayout(
{
backgroundColor: slide.backgroundColor,
...(slide.backgroundAssetPath ? { backgroundAssetPath: slide.backgroundAssetPath } : {}),
id: slide.layoutId,
name: slide.layoutName ?? slide.layoutId,
objects: slide.layoutObjects,
Expand Down Expand Up @@ -502,6 +529,18 @@ function map(deck: PptxDeck, pptxPackage: PptxPackage): ProjectDocument {
? undefined
: createSlideFallbackLayout(slide, pptxPackage, assets, warnings, deck.width, deck.height);
if (layout) slideLayouts[layout.id] = layout;
const backgroundImage = mapBackgroundImage(
slide.backgroundAssetPath,
pptxPackage,
assets,
slide.id,
deck.width,
deck.height,
);
if (backgroundImage) {
elements[backgroundImage.id] = backgroundImage;
elementIds.push(backgroundImage.id);
}
for (const object of slide.objects.sort((left, right) => left.zIndex - right.zIndex)) {
const element = mapObject(
object,
Expand All @@ -511,7 +550,7 @@ function map(deck: PptxDeck, pptxPackage: PptxPackage): ProjectDocument {
slide.id,
deck.width,
deck.height,
slide.backgroundColor,
slide.backgroundAssetPath ? undefined : slide.backgroundColor,
);
if (!element) continue;
elements[element.id] = element;
Expand Down
12 changes: 9 additions & 3 deletions apps/editor/src/services/importing/pptx/pptxTextParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,14 @@ function getTextBody(shape: Element) {
return pptxXml.firstDescendant(shape, 'txBody');
}

function getFirstRunProperties(paragraph: Element | undefined) {
const run = paragraph ? pptxXml.firstDescendant(paragraph, 'r') : undefined;
function getDominantRunProperties(paragraph: Element | undefined) {
const runs = paragraph ? pptxXml.descendants(paragraph, 'r') : [];
const run = runs.reduce<Element | undefined>((dominant, candidate) => {
if (!dominant) return candidate;
const dominantLength = pptxXml.textContent(dominant, 't').trim().length;
const candidateLength = pptxXml.textContent(candidate, 't').trim().length;
return candidateLength > dominantLength ? candidate : dominant;
}, undefined);
return run ? pptxXml.firstDescendant(run, 'rPr') : undefined;
}

Expand Down Expand Up @@ -156,7 +162,7 @@ function hasLineSpacing(...paragraphProperties: Array<Element | undefined>) {
function getLocalStyleSources(shape: Element) {
const paragraph = getFirstParagraph(shape);
const paragraphProperties = paragraph ? pptxXml.firstDescendant(paragraph, 'pPr') : undefined;
const runProperties = getFirstRunProperties(paragraph);
const runProperties = getDominantRunProperties(paragraph);
const paragraphDefaultRunProperties = getParagraphDefaultRunProperties(paragraphProperties);
const textBodyListDefaultRunProperties = getTextBodyListDefaultRunProperties(shape);
const listParagraphProperties = getListParagraphProperties(shape);
Expand Down
18 changes: 18 additions & 0 deletions apps/editor/src/services/storage/assetFileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ function dataUrlToBlob(dataUrl: string) {
return new Blob([bytes], { type: mimeType });
}

function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('Asset could not be read as a data URL.'));
});
reader.addEventListener('error', () => {
reject(reader.error ?? new Error('Asset could not be read.'));
});
reader.readAsDataURL(blob);
});
}

function isReadableObjectUrl(value: string | undefined): value is string {
return isDataUrl(value) || isBlobUrl(value);
}
Expand Down Expand Up @@ -79,6 +96,7 @@ export const assetFileUtils = {
isBlobUrl,
isSafeRemoteUrl,
dataUrlToBlob,
blobToDataUrl,
isReadableObjectUrl,
objectUrlToBlob,
objectUrlToBlobIfReadable,
Expand Down
55 changes: 51 additions & 4 deletions apps/editor/src/ui/editor/browser/editorShellBrowserUtils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { WebMcpModelContext } from '../../../services/webmcp/webMcpToolAdapter';
import { assetFileUtils } from '../../../services/storage/assetFileUtils';
import type { SlideClipboardState } from '../state/editorViewModelElements';

const EDITOR_OBJECT_CLIPBOARD_TYPE = 'application/x-localstudio-editor-elements';
const EDITOR_OBJECT_CLIPBOARD_MARKER = '1';
const MAX_EDITOR_OBJECT_CLIPBOARD_BYTES = 1024 * 1024;
const MAX_SLIDE_CLIPBOARD_BYTES = 16 * 1024 * 1024;
const SLIDE_CLIPBOARD_PREFIX = 'LocalStudio.dev slide: ';

function isEditableElement(target: EventTarget | null) {
Expand Down Expand Up @@ -66,21 +69,64 @@ function readEditorObjectClipboardPayload(clipboardData: DataTransfer | null) {
return payload;
}

async function writeSlideClipboardPayload(payload: string) {
if (payload.length > MAX_EDITOR_OBJECT_CLIPBOARD_BYTES || !navigator.clipboard?.writeText) return false;
async function resolveSlideClipboardPayload(payload: string | Promise<string>) {
const resolvedPayload = await payload;
if (resolvedPayload.length > MAX_SLIDE_CLIPBOARD_BYTES) {
throw new Error('Slide clipboard payload exceeds the supported size.');
}
return `${SLIDE_CLIPBOARD_PREFIX}${resolvedPayload}`;
}

async function writeSlideClipboardPayload(payload: string | Promise<string>) {
if (!navigator.clipboard) return false;
try {
await navigator.clipboard.writeText(`${SLIDE_CLIPBOARD_PREFIX}${payload}`);
if (navigator.clipboard.write && typeof ClipboardItem !== 'undefined') {
const clipboardText = resolveSlideClipboardPayload(payload);
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': clipboardText.then(
(text) => new Blob([text], { type: 'text/plain' }),
),
}),
]);
return true;
}
if (!navigator.clipboard.writeText) return false;
await navigator.clipboard.writeText(await resolveSlideClipboardPayload(payload));
return true;
} catch {
return false;
}
}

async function makeSlideClipboardPayloadTransferable(
payload: SlideClipboardState,
requestFetch: typeof fetch = globalThis.fetch.bind(globalThis),
) {
const assets = Object.fromEntries(
await Promise.all(
Object.entries(payload.assets).map(async ([assetId, asset]) => {
if (!assetFileUtils.isBlobUrl(asset.objectUrl)) return [assetId, asset] as const;
try {
const blob = await assetFileUtils.objectUrlToBlob(asset.objectUrl, requestFetch);
return [
assetId,
{ ...asset, objectUrl: await assetFileUtils.blobToDataUrl(blob) },
] as const;
} catch {
return [assetId, asset] as const;
}
}),
),
);
return { ...payload, assets };
}

function readSlideClipboardPayload(clipboardData: DataTransfer | null) {
const text = clipboardData?.getData?.('text/plain') ?? '';
if (!text.startsWith(SLIDE_CLIPBOARD_PREFIX)) return undefined;
const payload = text.slice(SLIDE_CLIPBOARD_PREFIX.length);
return payload.length <= MAX_EDITOR_OBJECT_CLIPBOARD_BYTES ? payload : undefined;
return payload.length <= MAX_SLIDE_CLIPBOARD_BYTES ? payload : undefined;
}

function isWebMcpEnabled() {
Expand All @@ -107,6 +153,7 @@ export const editorShellBrowserUtils = {
writeEditorObjectClipboardPayload,
readEditorObjectClipboardPayload,
writeSlideClipboardPayload,
makeSlideClipboardPayloadTransferable,
readSlideClipboardPayload,
isWebMcpEnabled,
isWebMcpProtocolEnabled,
Expand Down
17 changes: 16 additions & 1 deletion apps/editor/src/ui/editor/canvas/CanvasWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type RefObject,
} from 'react';
import type Konva from 'konva';
import { Circle, Group, Layer, Rect, Stage, Text, Transformer } from 'react-konva';
import { Circle, Group, Image as KonvaImage, Layer, Rect, Stage, Text, Transformer } from 'react-konva';
import type {
AlignMode,
ElementFramePatch,
Expand Down Expand Up @@ -255,6 +255,11 @@ export function CanvasWorkspace({
| undefined
>();
const page = project.pages.find((item) => item.id === activePageId) ?? project.pages[0];
const pageBackgroundAssetUrl =
page?.background.type === 'asset'
? project.assets[page.background.assetId]?.objectUrl
: undefined;
const pageBackgroundImage = canvasWorkspaceUtils.useCanvasImage(pageBackgroundAssetUrl);
const activeLayout = page?.layoutId ? project.slideLayouts?.[page.layoutId] : undefined;
const layoutVisibleElements = useMemo(
() =>
Expand Down Expand Up @@ -1268,6 +1273,16 @@ export function CanvasWorkspace({
x={0}
y={0}
/>
{pageBackgroundImage ? (
<KonvaImage
height={stageHeight}
image={pageBackgroundImage}
listening={false}
width={stageWidth}
x={0}
y={0}
/>
) : null}
{visibleElements.map((element) => {
const animationState = getElementAnimationState(element);
const isLayoutElement = layoutVisibleElementIds.has(element.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type ReactNode,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
} from 'react';
import type { ElementStylePatch } from '../../../domain/commands/elements/basicCommands';
Expand Down Expand Up @@ -88,7 +89,7 @@ export const ScrollingCanvasWorkspace = forwardRef<HTMLDivElement, ScrollingCanv
activePageIndex === project.pages.length - 1 ? activePageIndex - 1 : activePageIndex + 1;
useImperativeHandle(ref, () => scrollerRef.current as HTMLDivElement, []);

useEffect(() => {
useLayoutEffect(() => {
if (ignoreNextActiveScrollRef.current) {
ignoreNextActiveScrollRef.current = false;
return;
Expand Down
Loading
Loading