mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 16:25:35 +00:00
Make the export experience clearer and more useful (#857)
Hide the legacy pipeline choice while retaining the internal fallback, and improve Lightning exports with rotating localised tips and announcement messages.
This commit is contained in:
@@ -102,7 +102,8 @@ export function AnnouncementDialog({ audience }: { audience: AnnouncementAudienc
|
||||
}).filter(
|
||||
(announcement) =>
|
||||
announcement.presentation !== "notification" &&
|
||||
announcement.presentation !== "banner",
|
||||
announcement.presentation !== "banner" &&
|
||||
announcement.presentation !== "export",
|
||||
);
|
||||
setPopupAspectRatio(
|
||||
parsedRemoteFeed.settings.aspectRatio ??
|
||||
|
||||
@@ -25,7 +25,6 @@ interface ExportSettingsMenuProps {
|
||||
mp4FrameRate: ExportMp4FrameRate;
|
||||
onMp4FrameRateChange?: (frameRate: ExportMp4FrameRate) => void;
|
||||
exportPipelineModel?: ExportPipelineModel;
|
||||
onExportPipelineModelChange?: (pipelineModel: ExportPipelineModel) => void;
|
||||
experimentalNvidiaCudaExport?: boolean;
|
||||
onExperimentalNvidiaCudaExportChange?: (enabled: boolean) => void;
|
||||
nvidiaCudaExportAvailable?: boolean;
|
||||
@@ -54,7 +53,6 @@ export function ExportSettingsMenu({
|
||||
mp4FrameRate,
|
||||
onMp4FrameRateChange,
|
||||
exportPipelineModel = "modern",
|
||||
onExportPipelineModelChange,
|
||||
experimentalNvidiaCudaExport = false,
|
||||
onExperimentalNvidiaCudaExportChange,
|
||||
nvidiaCudaExportAvailable = false,
|
||||
@@ -281,69 +279,6 @@ export function ExportSettingsMenu({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mb-1 flex items-center justify-between px-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/70">
|
||||
{tSettings("export.pipelineTitle", "Pipeline")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3 grid min-h-10 w-full grid-cols-2 rounded-xl border border-foreground/5 bg-foreground/5 p-0.5">
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: "legacy",
|
||||
label: tSettings("export.pipeline.legacy", "Legacy"),
|
||||
},
|
||||
{
|
||||
value: "modern",
|
||||
label: tSettings("export.pipeline.modern", "Lightning (Beta)"),
|
||||
},
|
||||
] as const
|
||||
).map((option) => {
|
||||
const isActive = exportPipelineModel === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onExportPipelineModelChange?.(option.value)}
|
||||
aria-pressed={isActive}
|
||||
className="relative rounded-lg px-1 py-1 text-[11px] font-medium transition-colors"
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="header-export-pipeline-pill"
|
||||
className="absolute inset-0 rounded-lg bg-neutral-800 dark:bg-white"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 420,
|
||||
damping: 34,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10",
|
||||
isActive
|
||||
? "text-white dark:text-black"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mb-3 px-1 text-[10px] text-muted-foreground/70">
|
||||
{isLegacyModel
|
||||
? tSettings(
|
||||
"export.pipeline.legacyHint",
|
||||
"Legacy uses the current stable WebCodecs export path.",
|
||||
)
|
||||
: tSettings(
|
||||
"export.pipeline.lightningHint",
|
||||
"Lightning (Beta) automatically uses the fastest compatible backend and falls back when needed.",
|
||||
)}
|
||||
</p>
|
||||
{!isLegacyModel && nvidiaCudaExportAvailable ? (
|
||||
<div className="mb-3 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-[#2563EB]/20 bg-[#2563EB]/5 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -97,6 +97,12 @@ describe("editorPreferences", () => {
|
||||
expect(DEFAULT_EDITOR_PREFERENCES.exportPipelineModel).toBe("modern");
|
||||
});
|
||||
|
||||
it("migrates hidden legacy pipeline preferences to Lightning", () => {
|
||||
expect(normalizeEditorPreferences({ exportPipelineModel: "legacy" })).toMatchObject({
|
||||
exportPipelineModel: "modern",
|
||||
});
|
||||
});
|
||||
|
||||
it("bakes in the stronger split motion blur defaults", () => {
|
||||
expect(DEFAULT_EDITOR_PREFERENCES.zoomMotionBlurTuning).toMatchObject({
|
||||
panVelocityThreshold: 0,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { useI18n } from "@/contexts/I18nContext";
|
||||
import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio";
|
||||
import type { getSmokeExportConfig } from "../smokeExportConfig";
|
||||
import type { useAppearanceState } from "../state/useAppearanceState";
|
||||
import type { useTimelineState } from "../state/useTimelineState";
|
||||
import { openExternalLink, RECORDLY_ISSUES_URL } from "../TutorialHelp";
|
||||
import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types";
|
||||
import type { VideoPlaybackRef } from "../VideoPlayback";
|
||||
import { useExportDialogActions } from "./useExportDialogActions";
|
||||
import type { useExportDimensions } from "./useExportDimensions";
|
||||
import { useExportMessages } from "./useExportMessages";
|
||||
import { useExportRunner } from "./useExportRunner";
|
||||
import type { useExportSession } from "./useExportSession";
|
||||
import type { useExportSettings } from "./useExportSettings";
|
||||
@@ -91,12 +90,10 @@ export function useEditorExportController(input: Input) {
|
||||
session: input.session,
|
||||
settings: input.settings,
|
||||
});
|
||||
const openLightningIssues = useCallback(async () => {
|
||||
await openExternalLink(
|
||||
RECORDLY_ISSUES_URL,
|
||||
input.t("editor.feedback.openFailed", "Failed to open link."),
|
||||
);
|
||||
}, [input.t]);
|
||||
const exportMessage = useExportMessages({
|
||||
t: input.t,
|
||||
active: status.isLightningExportInProgress,
|
||||
});
|
||||
|
||||
return { dialogActions, status, openLightningIssues };
|
||||
return { dialogActions, status, exportMessage };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExportMessageStream, type ExportMessage } from "./useExportMessages";
|
||||
|
||||
function message(id: string): ExportMessage {
|
||||
return { id, text: id, durationSeconds: 6 };
|
||||
}
|
||||
|
||||
describe("buildExportMessageStream", () => {
|
||||
it("alternates built-in tips with streamed announcements", () => {
|
||||
expect(
|
||||
buildExportMessageStream(
|
||||
[message("tip-1"), message("tip-2"), message("tip-3")],
|
||||
[message("announcement-1"), message("announcement-2")],
|
||||
).map(({ id }) => id),
|
||||
).toEqual(["tip-1", "announcement-1", "tip-2", "announcement-2", "tip-3"]);
|
||||
});
|
||||
|
||||
it("uses only built-in tips when the announcement feed is empty", () => {
|
||||
expect(
|
||||
buildExportMessageStream([message("tip-1"), message("tip-2")], []).map(({ id }) => id),
|
||||
).toEqual(["tip-1", "tip-2"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements";
|
||||
import type { useI18n } from "@/contexts/I18nContext";
|
||||
import {
|
||||
readAnnouncementImpressionCounts,
|
||||
readDismissedAnnouncementIds,
|
||||
recordAnnouncementImpression,
|
||||
} from "@/lib/announcementState";
|
||||
import type { Announcement } from "@/lib/announcements";
|
||||
import { parseAnnouncementFeed, selectAnnouncements } from "@/lib/announcements";
|
||||
|
||||
const DEFAULT_MESSAGE_DURATION_SECONDS = 6;
|
||||
const MAX_EXPORT_MESSAGE_LENGTH = 220;
|
||||
|
||||
export type ExportMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
durationSeconds: number;
|
||||
announcementId?: string;
|
||||
};
|
||||
|
||||
function compactMessage(value: string): string {
|
||||
const compacted = value.replace(/\s+/g, " ").trim();
|
||||
if (compacted.length <= MAX_EXPORT_MESSAGE_LENGTH) return compacted;
|
||||
return `${compacted.slice(0, MAX_EXPORT_MESSAGE_LENGTH - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function buildExportMessageStream(
|
||||
tips: ExportMessage[],
|
||||
announcements: ExportMessage[],
|
||||
): ExportMessage[] {
|
||||
const stream: ExportMessage[] = [];
|
||||
const length = Math.max(tips.length, announcements.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (tips[index]) stream.push(tips[index]);
|
||||
if (announcements[index]) stream.push(announcements[index]);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
function toExportMessage(announcement: Announcement): ExportMessage {
|
||||
return {
|
||||
id: `announcement:${announcement.id}`,
|
||||
announcementId: announcement.id,
|
||||
text: compactMessage(`${announcement.title} — ${announcement.body}`),
|
||||
durationSeconds: announcement.displayDurationSeconds ?? DEFAULT_MESSAGE_DURATION_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export function useExportMessages({
|
||||
t,
|
||||
active,
|
||||
}: {
|
||||
t: ReturnType<typeof useI18n>["t"];
|
||||
active: boolean;
|
||||
}): string | null {
|
||||
const [announcementMessages, setAnnouncementMessages] = useState<ExportMessage[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const countedThisSessionRef = useRef(new Set<string>());
|
||||
const tips = useMemo<ExportMessage[]>(
|
||||
() => [
|
||||
{
|
||||
id: "tip:auto-zooms",
|
||||
text: t(
|
||||
"editor.exportTips.autoZooms",
|
||||
"Tip: Turn off auto-applied zooms in settings",
|
||||
),
|
||||
durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS,
|
||||
},
|
||||
{
|
||||
id: "tip:experimental-builds",
|
||||
text: t(
|
||||
"editor.exportTips.experimentalBuilds",
|
||||
"Tip: Try experimental builds by turning on access in settings",
|
||||
),
|
||||
durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS,
|
||||
},
|
||||
{
|
||||
id: "tip:cursor-appearance",
|
||||
text: t(
|
||||
"editor.exportTips.cursorAppearance",
|
||||
"Tip: You can customise your cursor appearance",
|
||||
),
|
||||
durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const messages = useMemo(
|
||||
() => buildExportMessageStream(tips, announcementMessages),
|
||||
[tips, announcementMessages],
|
||||
);
|
||||
const current = messages[currentIndex % messages.length];
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadExportAnnouncements = async () => {
|
||||
const dismissedIds = new Set(readDismissedAnnouncementIds());
|
||||
const impressionCounts = readAnnouncementImpressionCounts();
|
||||
const [appVersion, remoteFeed] = await Promise.all([
|
||||
window.electronAPI.getAppVersion().catch(() => "0.0.0"),
|
||||
window.electronAPI.getAnnouncements().catch(() => null),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setAnnouncementMessages(
|
||||
selectAnnouncements({
|
||||
bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements,
|
||||
remote: parseAnnouncementFeed(remoteFeed).announcements,
|
||||
dismissedIds,
|
||||
impressionCounts,
|
||||
appVersion,
|
||||
audience: "editor",
|
||||
})
|
||||
.filter((announcement) => announcement.presentation === "export")
|
||||
.map(toExportMessage),
|
||||
);
|
||||
};
|
||||
void loadExportAnnouncements();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || messages.length < 2 || !current) return;
|
||||
const timeout = window.setTimeout(
|
||||
() => setCurrentIndex((index) => (index + 1) % messages.length),
|
||||
current.durationSeconds * 1_000,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [active, current, messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const announcementId = active ? current?.announcementId : undefined;
|
||||
if (!announcementId || countedThisSessionRef.current.has(announcementId)) return;
|
||||
countedThisSessionRef.current.add(announcementId);
|
||||
recordAnnouncementImpression(announcementId);
|
||||
}, [active, current]);
|
||||
|
||||
return active ? (current?.text ?? null) : null;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ type Props = {
|
||||
handleRetrySaveExport: () => void;
|
||||
handleStartExportFromDropdown: () => void;
|
||||
revealExportedFile: () => void;
|
||||
openLightningIssues: () => void;
|
||||
exportMessage: string | null;
|
||||
};
|
||||
|
||||
export function EditorExportMenu(props: Props) {
|
||||
@@ -48,7 +48,7 @@ export function EditorExportMenu(props: Props) {
|
||||
handleRetrySaveExport,
|
||||
handleStartExportFromDropdown,
|
||||
revealExportedFile,
|
||||
openLightningIssues,
|
||||
exportMessage,
|
||||
} = props;
|
||||
const {
|
||||
exportQuality,
|
||||
@@ -56,7 +56,6 @@ export function EditorExportMenu(props: Props) {
|
||||
exportEncodingMode,
|
||||
setExportEncodingMode,
|
||||
exportPipelineModel,
|
||||
setExportPipelineModel,
|
||||
mp4FrameRate,
|
||||
setMp4FrameRate,
|
||||
exportFormat,
|
||||
@@ -123,18 +122,9 @@ export function EditorExportMenu(props: Props) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("editor.exportStatus.renderingFile", "Rendering your file.")}
|
||||
</p>
|
||||
{isLightningExportInProgress ? (
|
||||
<p className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
PLEASE{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openLightningIssues()}
|
||||
className="underline decoration-slate-500/70 underline-offset-2 transition-colors hover:text-foreground"
|
||||
>
|
||||
report bugs
|
||||
</button>
|
||||
with Lightning export{" "}
|
||||
<span aria-hidden="true">{"\u{1F64F}"}</span>
|
||||
{isLightningExportInProgress && exportMessage ? (
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground/70">
|
||||
{exportMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{isLegacyExportInProgress ? (
|
||||
@@ -269,7 +259,6 @@ export function EditorExportMenu(props: Props) {
|
||||
mp4FrameRate={mp4FrameRate}
|
||||
onMp4FrameRateChange={setMp4FrameRate}
|
||||
exportPipelineModel={exportPipelineModel}
|
||||
onExportPipelineModelChange={setExportPipelineModel}
|
||||
experimentalNvidiaCudaExport={
|
||||
experimentalNvidiaCudaExport && nvidiaCudaExportAvailable
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ type Props = {
|
||||
handleRetrySaveExport: () => void;
|
||||
handleStartExportFromDropdown: () => void;
|
||||
revealExportedFile: () => void;
|
||||
openLightningIssues: () => void;
|
||||
exportMessage: string | null;
|
||||
};
|
||||
|
||||
export function EditorHeader(props: Props) {
|
||||
@@ -80,7 +80,7 @@ export function EditorHeader(props: Props) {
|
||||
handleRetrySaveExport,
|
||||
handleStartExportFromDropdown,
|
||||
revealExportedFile,
|
||||
openLightningIssues,
|
||||
exportMessage,
|
||||
} = props;
|
||||
const {
|
||||
isEditingProjectName,
|
||||
@@ -219,7 +219,7 @@ export function EditorHeader(props: Props) {
|
||||
handleRetrySaveExport={handleRetrySaveExport}
|
||||
handleStartExportFromDropdown={handleStartExportFromDropdown}
|
||||
revealExportedFile={revealExportedFile}
|
||||
openLightningIssues={openLightningIssues}
|
||||
exportMessage={exportMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export function EditorShell(props: Props) {
|
||||
handleSelectAnnotation,
|
||||
handleAutoSuggestZoomsConsumed,
|
||||
} = editing;
|
||||
const { dialogActions, status: exportStatus, openLightningIssues } = exportController;
|
||||
const { dialogActions, status: exportStatus, exportMessage } = exportController;
|
||||
const editorDialogs = (
|
||||
<EditorDialogs
|
||||
t={t}
|
||||
@@ -176,7 +176,7 @@ export function EditorShell(props: Props) {
|
||||
handleRetrySaveExport={dialogActions.handleRetrySaveExport}
|
||||
handleStartExportFromDropdown={dialogActions.handleStartExportFromDropdown}
|
||||
revealExportedFile={dialogActions.revealExportedFile}
|
||||
openLightningIssues={openLightningIssues}
|
||||
exportMessage={exportMessage}
|
||||
/>
|
||||
<EditorAnnouncementBanner />
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
|
||||
@@ -197,11 +197,9 @@ export function normalizeExportBackendPreference(value: unknown): ExportBackendP
|
||||
return "auto";
|
||||
}
|
||||
|
||||
export function normalizeExportPipelineModel(value: unknown): ExportPipelineModel {
|
||||
if (value === "modern" || value === "legacy") {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeExportPipelineModel(_value: unknown): ExportPipelineModel {
|
||||
// Legacy remains available to internal smoke/export routing, but persisted
|
||||
// user selections migrate to the only pipeline exposed by the editor UI.
|
||||
return "modern";
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,11 @@
|
||||
"discardChanges": "Änderungen verwerfen",
|
||||
"saveProject": "Projekt speichern"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "Tipp: Automatisch angewendete Zooms können in den Einstellungen deaktiviert werden",
|
||||
"experimentalBuilds": "Tipp: Aktiviere den Zugriff auf experimentelle Builds in den Einstellungen",
|
||||
"cursorAppearance": "Tipp: Du kannst das Aussehen deines Cursors anpassen"
|
||||
},
|
||||
"account": { "title": "Konto", "comingSoon": "Konto demnächst verfügbar" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "Es ist nichts kaputt, aber wir können kein animiertes Cursor-Overlay rendern.",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"discardChanges": "Discard changes",
|
||||
"saveProject": "Save project"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "Tip: Turn off auto-applied zooms in settings",
|
||||
"experimentalBuilds": "Tip: Try experimental builds by turning on access in settings",
|
||||
"cursorAppearance": "Tip: You can customise your cursor appearance"
|
||||
},
|
||||
"account": { "title": "Account", "comingSoon": "Account coming soon" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"discardChanges": "Descartar cambios",
|
||||
"saveProject": "Guardar proyecto"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "Consejo: Desactiva los zooms automáticos en la configuración",
|
||||
"experimentalBuilds": "Consejo: Activa el acceso a versiones experimentales en la configuración",
|
||||
"cursorAppearance": "Consejo: Puedes personalizar la apariencia del cursor"
|
||||
},
|
||||
"account": { "title": "Cuenta", "comingSoon": "Cuenta próximamente" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"discardChanges": "Ignorer les modifications",
|
||||
"saveProject": "Enregistrer le projet"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "Astuce : Désactivez les zooms automatiques dans les paramètres",
|
||||
"experimentalBuilds": "Astuce : Activez l’accès aux versions expérimentales dans les paramètres",
|
||||
"cursorAppearance": "Astuce : Vous pouvez personnaliser l’apparence du curseur"
|
||||
},
|
||||
"account": { "title": "Compte", "comingSoon": "Compte bientôt disponible" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"discardChanges": "Ignora modifiche",
|
||||
"saveProject": "Salva progetto"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "Suggerimento: Disattiva gli zoom applicati automaticamente nelle impostazioni",
|
||||
"experimentalBuilds": "Suggerimento: Attiva l’accesso alle build sperimentali nelle impostazioni",
|
||||
"cursorAppearance": "Suggerimento: Puoi personalizzare l’aspetto del cursore"
|
||||
},
|
||||
"account": { "title": "Account", "comingSoon": "Account in arrivo" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.",
|
||||
|
||||
@@ -119,6 +119,11 @@
|
||||
"discardChanges": "변경 사항 버리기",
|
||||
"saveProject": "프로젝트 저장"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "팁: 설정에서 자동 적용 확대를 끌 수 있습니다",
|
||||
"experimentalBuilds": "팁: 설정에서 실험적 빌드 액세스를 켜 보세요",
|
||||
"cursorAppearance": "팁: 커서 모양을 사용자 지정할 수 있습니다"
|
||||
},
|
||||
"account": { "title": "계정", "comingSoon": "계정 기능 준비 중" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.",
|
||||
|
||||
@@ -119,6 +119,11 @@
|
||||
"discardChanges": "Wijzigingen negeren",
|
||||
"saveProject": "Project opslaan"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "Tip: Schakel automatisch toegepaste zooms uit in de instellingen",
|
||||
"experimentalBuilds": "Tip: Schakel toegang tot experimentele builds in via de instellingen",
|
||||
"cursorAppearance": "Tip: Je kunt het uiterlijk van je cursor aanpassen"
|
||||
},
|
||||
"account": { "title": "Account", "comingSoon": "Account binnenkort beschikbaar" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"discardChanges": "Descartar alterações",
|
||||
"saveProject": "Salvar projeto"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "Dica: Desative os zooms aplicados automaticamente nas configurações",
|
||||
"experimentalBuilds": "Dica: Ative o acesso a versões experimentais nas configurações",
|
||||
"cursorAppearance": "Dica: Você pode personalizar a aparência do cursor"
|
||||
},
|
||||
"account": { "title": "Conta", "comingSoon": "Conta em breve" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"discardChanges": "Отменить изменения",
|
||||
"saveProject": "Сохранить проект"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "Совет: Автоматические приближения можно отключить в настройках",
|
||||
"experimentalBuilds": "Совет: Включите доступ к экспериментальным сборкам в настройках",
|
||||
"cursorAppearance": "Совет: Внешний вид курсора можно настроить"
|
||||
},
|
||||
"account": { "title": "Учётная запись", "comingSoon": "Учётная запись скоро появится" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"discardChanges": "放弃更改",
|
||||
"saveProject": "保存项目"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "提示:可在设置中关闭自动应用的缩放",
|
||||
"experimentalBuilds": "提示:可在设置中开启实验版本访问权限",
|
||||
"cursorAppearance": "提示:你可以自定义光标外观"
|
||||
},
|
||||
"account": { "title": "账户", "comingSoon": "账户功能即将推出" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "没有出错,但我们无法渲染动画光标叠加层。",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"discardChanges": "捨棄變更",
|
||||
"saveProject": "儲存專案"
|
||||
},
|
||||
"exportTips": {
|
||||
"autoZooms": "提示:可在設定中關閉自動套用的縮放",
|
||||
"experimentalBuilds": "提示:可在設定中開啟實驗版本存取權",
|
||||
"cursorAppearance": "提示:你可以自訂游標外觀"
|
||||
},
|
||||
"account": { "title": "帳號", "comingSoon": "帳號功能即將推出" },
|
||||
"nativeCaptureUnavailable": {
|
||||
"title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。",
|
||||
|
||||
@@ -116,6 +116,33 @@ describe("parseAnnouncementFeed", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts text-only messages for the export status stream", () => {
|
||||
const feed = parseAnnouncementFeed({
|
||||
announcements: [
|
||||
{
|
||||
id: "export-tip-1",
|
||||
title: "Did you know?",
|
||||
body: "Cursor styles can be changed in the editor.",
|
||||
presentation: "export",
|
||||
media: { type: "image", url: "https://example.com/ignored.jpg" },
|
||||
displayDurationSeconds: 8,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(feed.announcements).toEqual([
|
||||
{
|
||||
id: "export-tip-1",
|
||||
title: "Did you know?",
|
||||
body: "Cursor styles can be changed in the editor.",
|
||||
presentation: "export",
|
||||
audience: "all",
|
||||
priority: 0,
|
||||
displayDurationSeconds: 8,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts an action that opens a safe editor section", () => {
|
||||
const feed = parseAnnouncementFeed({
|
||||
announcements: [
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
presentation?: "popup" | "notification" | "banner";
|
||||
presentation?: "popup" | "notification" | "banner" | "export";
|
||||
audience: AnnouncementAudience;
|
||||
priority: number;
|
||||
mediaMode?: "banner" | "cover";
|
||||
@@ -203,10 +203,13 @@ function parseAnnouncement(value: unknown): Announcement | undefined {
|
||||
|
||||
const audience = value.audience === "editor" ? "editor" : "all";
|
||||
const presentation =
|
||||
value.presentation === "notification" || value.presentation === "banner"
|
||||
value.presentation === "notification" ||
|
||||
value.presentation === "banner" ||
|
||||
value.presentation === "export"
|
||||
? value.presentation
|
||||
: undefined;
|
||||
const isTextOnlyPresentation = presentation === "notification" || presentation === "banner";
|
||||
const isTextOnlyPresentation =
|
||||
presentation === "notification" || presentation === "banner" || presentation === "export";
|
||||
const priority =
|
||||
typeof value.priority === "number" && Number.isFinite(value.priority)
|
||||
? Math.max(-100, Math.min(100, value.priority))
|
||||
|
||||
Reference in New Issue
Block a user