Skip to content

Commit 1a7a23d

Browse files
authored
fix(community): make deck remix previews reliable (#6996)
* fix(community): make deck remix previews reliable * fix(community): support nested deck scrollers * fix(web): isolate nested deck fallback scrolling * fix(web): infer deck scroll axis from slide geometry
1 parent f0270af commit 1a7a23d

14 files changed

Lines changed: 920 additions & 113 deletions

apps/daemon/src/routes/plugins/index.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,25 @@ export interface RegisterPluginRoutesDeps {
281281
helpers: PluginRouteHelpers;
282282
}
283283

284+
function duplicatedProjectKind(plugin: InstalledPluginLike): ProjectMetadata['kind'] {
285+
const od = plugin.manifest?.['od'];
286+
const mode = od && typeof od === 'object'
287+
? (od as { mode?: unknown }).mode
288+
: null;
289+
switch (mode) {
290+
case 'deck':
291+
case 'image':
292+
case 'video':
293+
case 'audio':
294+
case 'brand':
295+
case 'template':
296+
case 'prototype':
297+
return mode;
298+
default:
299+
return 'prototype';
300+
}
301+
}
302+
284303
export function registerPluginEventRoutes(app: Express, deps: RegisterPluginEventRoutesDeps): void {
285304
const resolveEventScope = async (req: Request, res: Response) => {
286305
const authority = await resolveOptionalWorkspaceRequestAuthority(
@@ -719,7 +738,11 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
719738
const conversationId = ids.randomId();
720739
cleanupProjectId = projectId;
721740
const metadata: ProjectMetadata = {
722-
kind: 'prototype',
741+
// Preserve the plugin's artifact contract. Treating every duplicate as
742+
// a prototype made deck behavior depend on the copied HTML happening
743+
// to match the viewer's heuristic; fixed-canvas/vertical deck examples
744+
// then opened without slide chrome or a thumbnail rail.
745+
kind: duplicatedProjectKind(plugin),
723746
templateId: `plugin:${plugin.id}`,
724747
templateLabel: plugin.title || plugin.id,
725748
duplicatedFromPluginId: plugin.id,

apps/daemon/tests/plugins-duplicate-project.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ describe('plugin project duplication', () => {
205205
const root = await makeTempRoot('od-plugin-duplicate-workspace-');
206206
const projectsRoot = path.join(root, 'projects');
207207
const plugin = await makePreviewPlugin(root, 'workspace-plugin-fixture');
208+
(plugin.manifest.od as { mode?: string }).mode = 'deck';
208209
const projectId = 'workspace-plugin-project';
209210
const project = {
210211
id: projectId,
@@ -234,6 +235,10 @@ describe('plugin project duplication', () => {
234235
transactionSteps.push('workspace:bind');
235236
return input;
236237
});
238+
const insertProjectMock = vi.fn(() => {
239+
transactionSteps.push('project:insert');
240+
return project;
241+
});
237242
const app = express();
238243
app.use(express.json());
239244
registerPluginRoutes(app, {
@@ -249,10 +254,7 @@ describe('plugin project duplication', () => {
249254
.mockReturnValueOnce('workspace-plugin-conversation'),
250255
},
251256
projectStore: {
252-
insertProject: vi.fn(() => {
253-
transactionSteps.push('project:insert');
254-
return project;
255-
}),
257+
insertProject: insertProjectMock,
256258
getProject: vi.fn(() => project),
257259
ensureWorkspaceProject,
258260
dbDeleteProject: vi.fn(),
@@ -298,6 +300,12 @@ describe('plugin project duplication', () => {
298300
},
299301
);
300302
expect(resp.status).toBe(201);
303+
expect(insertProjectMock).toHaveBeenCalledWith(
304+
db,
305+
expect.objectContaining({
306+
metadata: expect.objectContaining({ kind: 'deck' }),
307+
}),
308+
);
301309
expect(ensureWorkspaceProject).toHaveBeenCalledWith(
302310
db,
303311
expect.objectContaining({

apps/web/src/components/CommunityView.tsx

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@ import { useI18n } from '../i18n';
55
import { listPlugins } from '../state/projects';
66
import {
77
buildCommunityTemplates,
8-
copyTemplatePrompt,
98
isPromptArtifact,
10-
templateActionLabel,
119
TEMPLATE_TYPE_LABEL_KEY,
1210
TEMPLATE_TYPE_ORDER,
1311
type TemplateDemo,
1412
type TemplateType,
1513
} from './CommunityTemplatePreview';
1614
import { MediaSurface } from './plugins-home/cards/MediaSurface';
15+
import { canDuplicatePluginPreview } from './plugins-home/duplicate';
1716
import { PluginDetailsModal } from './PluginDetailsModal';
1817
import type { PluginUseAction } from './plugins-home/useActions';
1918
import { useInView } from './plugins-home/useInView';
@@ -102,9 +101,9 @@ export function CommunityView({ onRemixTemplate, onUsePrompt, onUsePlugin }: Com
102101
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
103102
const [activeType, setActiveType] = useState<TemplateType>('Slides');
104103
const [activeSubtype, setActiveSubtype] = useState('All');
105-
// Remix (and the prompt-artifact copy path it shares) hands off to a
106-
// fire-and-forget parent callback (`onRemixTemplate`/`onUsePrompt` return
107-
// void) that kicks off a real POST /api/projects — nothing here observes
104+
// Remix hands off to a fire-and-forget parent callback
105+
// (`onRemixTemplate` returns void) that kicks off a real POST /api/projects
106+
// — nothing here observes
108107
// when it settles. Without a guard, N rapid clicks before the resulting
109108
// navigation actually leaves this view fired N separate creates,
110109
// duplicating the project N times ("Community 的模板 remix 点击多次会复制
@@ -147,6 +146,10 @@ export function CommunityView({ onRemixTemplate, onUsePrompt, onUsePlugin }: Com
147146
() => buildCommunityTemplates(plugins, locale, t, workspaceContext),
148147
[plugins, locale, t, workspaceContext],
149148
);
149+
const pluginById = useMemo(
150+
() => new Map(plugins.map((record) => [record.id, record])),
151+
[plugins],
152+
);
150153
const typeOptions = TEMPLATE_TYPE_ORDER.filter((type) =>
151154
templates.some((template) => template.type === type),
152155
);
@@ -165,19 +168,6 @@ export function CommunityView({ onRemixTemplate, onUsePrompt, onUsePlugin }: Com
165168
return sourceKind === 'bundled' || sourceKind === 'marketplace' ? 'official' as const : 'personal' as const;
166169
};
167170
const handleTemplateAction = (template: TemplateDemo) => {
168-
if (isPromptArtifact(template)) {
169-
trackCommunityTemplateClick(analytics.track, {
170-
page_name: 'community',
171-
area: 'community_templates',
172-
element: 'copy_prompt',
173-
template_key: template.id,
174-
template_type: template.type,
175-
resource_scope: templateScope(template.id),
176-
...workspaceDimensions,
177-
});
178-
void copyTemplatePrompt(template);
179-
return;
180-
}
181171
// Synchronous check-and-set on the ref: this is what actually decides
182172
// whether a request goes out. See the remixingIdRef comment above for
183173
// why the state flag alone cannot gate this.
@@ -195,6 +185,28 @@ export function CommunityView({ onRemixTemplate, onUsePrompt, onUsePlugin }: Com
195185
setRemixingId(template.id);
196186
onRemixTemplate?.({ templateId: template.id, prompt: template.prompt });
197187
};
188+
const handleCardUse = (template: TemplateDemo) => {
189+
const target = templateUseTarget(template);
190+
trackCommunityTemplateClick(analytics.track, {
191+
page_name: 'community',
192+
area: 'community_templates',
193+
element: 'use_prompt',
194+
template_key: template.id,
195+
template_type: template.type,
196+
resource_scope: templateScope(template.id),
197+
...workspaceDimensions,
198+
});
199+
const record = pluginById.get(template.id);
200+
if (record && onUsePlugin) {
201+
onUsePlugin(record, 'use-with-query', target);
202+
return;
203+
}
204+
onUsePrompt?.(target);
205+
};
206+
const canRemixTemplate = (template: TemplateDemo) => {
207+
const record = pluginById.get(template.id);
208+
return !isPromptArtifact(template) && Boolean(record && canDuplicatePluginPreview(record));
209+
};
198210
const templateById = useCallback(
199211
(id: string) => templates.find((template) => template.id === id) ?? null,
200212
[templates],
@@ -217,7 +229,7 @@ export function CommunityView({ onRemixTemplate, onUsePrompt, onUsePlugin }: Com
217229
/** The detail modal's Use split action. Shells that own a Home hand-off
218230
* route the plugin as the composer's active driver; without one, fall back
219231
* to seeding the composer with the template's prompt (same destination the
220-
* card's own prompt button uses). */
232+
* card's own Use button uses). */
221233
const handleDetailsUse = (record: InstalledPluginRecord, action: PluginUseAction) => {
222234
setDetailsRecord(null);
223235
const template = templateById(record.id);
@@ -330,7 +342,7 @@ export function CommunityView({ onRemixTemplate, onUsePrompt, onUsePlugin }: Com
330342
onClick={() => openTemplateDetails(template)}
331343
>
332344
<div
333-
className="community-template-card__preview"
345+
className={`community-template-card__preview${template.type === 'Slides' ? ' is-deck' : ''}`}
334346
style={{ '--template-accent': template.accent } as CSSProperties}
335347
aria-hidden
336348
>
@@ -339,34 +351,27 @@ export function CommunityView({ onRemixTemplate, onUsePrompt, onUsePlugin }: Com
339351
<footer className="community-template-card__foot">
340352
<span>{template.meta}</span>
341353
<div className="community-template-card__actions">
342-
<button
343-
type="button"
344-
disabled={remixingId === template.id}
345-
onClick={(event) => {
346-
event.stopPropagation();
347-
handleTemplateAction(template);
348-
}}
349-
>
350-
{remixingId === template.id ? t('common.loading') : templateActionLabel(template)}
351-
</button>
354+
{canRemixTemplate(template) ? (
355+
<button
356+
type="button"
357+
disabled={remixingId === template.id}
358+
onClick={(event) => {
359+
event.stopPropagation();
360+
handleTemplateAction(template);
361+
}}
362+
>
363+
{remixingId === template.id ? t('common.loading') : 'Remix'}
364+
</button>
365+
) : null}
352366
<button
353367
type="button"
354368
className="community-template-card__prompt-btn"
355369
onClick={(event) => {
356370
event.stopPropagation();
357-
trackCommunityTemplateClick(analytics.track, {
358-
page_name: 'community',
359-
area: 'community_templates',
360-
element: 'use_prompt',
361-
template_key: template.id,
362-
template_type: template.type,
363-
resource_scope: templateScope(template.id),
364-
...workspaceDimensions,
365-
});
366-
onUsePrompt?.(templateUseTarget(template));
371+
handleCardUse(template);
367372
}}
368373
>
369-
{t('community.usePrompt')}
374+
{t('pluginCard.use')}
370375
</button>
371376
</div>
372377
</footer>

apps/web/src/runtime/deck-thumbnail-parser.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface ParsedDeckThumbnails {
4444
reason?: DeckThumbnailFallbackReason;
4545
/** `outerHTML` of each slide, in document order. */
4646
slides: string[];
47-
/** Concatenated deck stylesheets, `:root`/`html`/`body` rewritten to `:host`,
47+
/** Concatenated deck stylesheets, root selectors rewritten for shadow DOM,
4848
* `@font-face` stripped (see `fontFaces`), relative `url()` absolutized. */
4949
styleText: string;
5050
/** `@font-face` blocks lifted out of `styleText` — must live in the host
@@ -240,8 +240,23 @@ interface DesignSize {
240240
// Design canvas size (viewport-unit decks are already excluded upstream):
241241
// explicit `<deck-stage width height>`, then an explicit px `width`+`height` on
242242
// a stage/slide rule, else the 1920×1080 default.
243-
const STAGE_SIZE_SELECTOR_RE =
244-
/(?:\bdeck-stage\b|\.deck-stage\b|\.canvas\b|#deck\b|\.deck\b|\.slide\b|\.ppt-slide\b|\.deck-slide\b|\[data-screen-label\])/i;
243+
const STAGE_SIZE_TARGET_RE =
244+
/(?:^|[^\w-])deck-stage(?![\w-])|(?:\.deck-stage|\.canvas|#deck|\.deck|\.slide|\.slide-frame|\.ppt-slide|\.deck-slide|\[data-screen-label(?:[\s~|^$*]?=[^\]]+)?\])(?![\w-])/i;
245+
246+
// A size declaration only describes the design canvas when the rule's TARGET
247+
// is a stage/slide. Merely mentioning `.slide` in an ancestor is insufficient:
248+
// real decks commonly contain rules such as `.slide .kicker-line { width:72px;
249+
// height:6px }`. Treating that decoration as the canvas collapses the whole
250+
// thumbnail into a 72x6 strip.
251+
function selectorTargetsStageOrSlide(selectorList: string): boolean {
252+
return selectorList.split(',').some((selector) => {
253+
const trimmed = selector.trim();
254+
if (!trimmed || /::(?:before|after)\b/i.test(trimmed)) return false;
255+
const compounds = trimmed.split(/\s+|[>+~]/).filter(Boolean);
256+
const target = compounds.at(-1) ?? '';
257+
return STAGE_SIZE_TARGET_RE.test(target);
258+
});
259+
}
245260

246261
function resolveDesignSize(doc: Document, css: string): DesignSize {
247262
const stage = doc.querySelector('deck-stage[width][height]');
@@ -254,7 +269,7 @@ function resolveDesignSize(doc: Document, css: string): DesignSize {
254269
}
255270

256271
for (const block of iterateRuleBlocks(css)) {
257-
if (!STAGE_SIZE_SELECTOR_RE.test(block.selector)) continue;
272+
if (!selectorTargetsStageOrSlide(block.selector)) continue;
258273
const width = matchPxLength(block.body, 'width');
259274
const height = matchPxLength(block.body, 'height');
260275
if (width && height) return { width, height };
@@ -296,13 +311,19 @@ function stripCssComments(css: string): string {
296311
return css.replace(/\/\*[\s\S]*?\*\//g, '');
297312
}
298313

299-
// Rewrite `:root`, `html`, and `body` (as standalone selectors in a selector
300-
// list) to `:host`, so the deck's custom properties, base font, and base color
301-
// land on the shadow host and inherit into the re-parented slide. Compound
302-
// selectors like `body.dark` are left untouched (they'd match nothing, but
303-
// forcing them onto `:host` risks unwanted rules).
314+
// Rewrite `:root`/`html` to `:host`, so document-level variables inherit into
315+
// the reconstructed slide. Body rules belong on the design canvas itself: host
316+
// page styles intentionally own the shadow host's dark thumbnail frame and win
317+
// over ordinary `:host` declarations, which used to hide transparent slides on
318+
// that dark frame. Applying body paint/layout to `.od-thumb-canvas` preserves
319+
// the source deck's paper/background inside the frame. Compound selectors like
320+
// `body.dark` are left untouched.
304321
function rewriteRootSelectors(css: string): string {
305-
return css.replace(/(^|[{};,])(\s*)(:root|html|body)(\s*)(?=[,{])/g, '$1$2:host$4');
322+
return css.replace(
323+
/(^|[{};,])(\s*)(:root|html|body)(\s*)(?=[,{])/g,
324+
(_whole, prefix: string, whitespace: string, selector: string, trailing: string) =>
325+
`${prefix}${whitespace}${selector.toLowerCase() === 'body' ? '.od-thumb-canvas' : ':host'}${trailing}`,
326+
);
306327
}
307328

308329
// Lift `@font-face` blocks out; they're ignored inside a shadow root and must be

0 commit comments

Comments
 (0)