Files
m3e-canvas/web/vendor/components/ShareMenu.tsx
T
pastilhasandClaude Fable 5 240d63940d vendor m3e-canvas 5c83ac6, with the import rewrite scripted
upstream imports through a @/ alias, and this platform already binds
@/* to src/apps/officer-web and @/components/* to its own shared
components — so an unrewritten import resolves to the WRONG module
rather than failing. the rewrite is mechanical and lives in
scripts/vendor.sh so a re-vendor produces a readable diff.

lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them
meant editing vendored code, and Canvas.tsx references them throughout.
a faithful copy carrying an unused module beats a doctored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:00:17 +01:00

249 lines
9.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Doc, Palette } from "../lib/tokens";
import { shareLink } from "../lib/share";
import { Icon } from "./M3Node";
import { t, useLang } from "../lib/i18n";
const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
/** the app's own URL without any hash, and the agent guide beside it */
const appUrl = () => `${window.location.origin}${BASE}/`;
const guideUrl = () => `${appUrl()}agent.md`;
/** the text button that opens the dialog: a label and a beta badge, no icon; busy while a model drafts */
export function ShareButton({ p, onClick, busy }: { p: Palette; onClick: () => void; busy?: boolean }) {
const lang = useLang();
return (
<button
onClick={onClick}
disabled={busy}
title={busy ? t("askAiGenerating", lang) : t("askAiTitle", lang)}
className="m3-press"
style={{
height: 40,
padding: "0 8px 0 16px",
borderRadius: 20,
border: "none",
background: "transparent",
color: busy ? p.onSurfaceVariant : p.onSurface,
fontSize: 13,
fontWeight: 600,
cursor: busy ? "default" : "pointer",
display: "inline-flex",
alignItems: "center",
gap: 8,
whiteSpace: "nowrap",
}}
>
{busy && <Icon name="hourglass_top" size={18} />}
{busy ? t("askAiGenerating", lang) : t("askAi", lang)}
{!busy && <Beta p={p} />}
</button>
);
}
const Beta = ({ p }: { p: Palette }) => (
<span style={{ fontSize: 10, fontWeight: 700, padding: "2px 7px", borderRadius: 8, background: p.tertiaryContainer, color: p.onTertiaryContainer, letterSpacing: 0.4 }}>BETA</span>
);
/** Ask an AI (beta): write the idea and have the author's own model draft it, or copy the
* instruction for a coding agent. The title row carries a link to the design as it is now. */
export function ShareDialog({
p,
doc,
aiReady,
idea,
onIdea,
open,
onClose,
onDraft,
onSetupAi,
}: {
p: Palette;
doc: Doc;
aiReady: boolean;
/** what to build, kept by the page so a failed draft does not lose it */
idea: string;
onIdea: (v: string) => void;
open: boolean;
onClose: () => void;
/** the idea, for the author's own model to draft */
onDraft: (idea: string) => void;
/** opens the AI settings so a key can be entered */
onSetupAi: () => void;
}) {
const lang = useLang();
const [copied, setCopied] = useState<"ask" | "link" | null>(null);
useEffect(() => {
if (!copied) return;
const id = setTimeout(() => setCopied(null), copied === "ask" ? 4000 : 1400);
return () => clearTimeout(id);
}, [copied]);
/* a fresh dialog starts without a "copied" mark; kept apart from the key handler, whose
onClose changes identity on every render of the page */
useEffect(() => {
if (open) setCopied(null);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [open, onClose]);
const copyAsk = async () => {
const text = t("askAiText", lang).replace("{url}", guideUrl()).replace("{idea}", idea.trim() || t("askAiIdeaFallback", lang));
try {
await navigator.clipboard.writeText(text);
setCopied("ask");
} catch {}
};
const copyLink = async () => {
try {
await navigator.clipboard.writeText(await shareLink(doc, appUrl()));
setCopied("link");
} catch {}
};
/* a connected pair, the way the canvas draws connected buttons: outer corners round, inner ones tight */
const pill = (icon: string, label: string, onClick: () => void, opts?: { primary?: boolean; disabled?: boolean; corners?: "left" | "right"; title?: string }) => {
const outer = 20;
const inner = 8;
const radius = opts?.corners === "left" ? `${outer}px ${inner}px ${inner}px ${outer}px` : opts?.corners === "right" ? `${inner}px ${outer}px ${outer}px ${inner}px` : outer;
return (
<button
onClick={onClick}
disabled={opts?.disabled}
title={opts?.title}
className="m3-press"
style={{
height: 40,
padding: "0 18px",
borderRadius: radius,
border: "none",
background: opts?.primary ? p.primary : p.secondaryContainer,
color: opts?.primary ? p.onPrimary : p.onSecondaryContainer,
opacity: opts?.disabled ? 0.5 : 1,
fontSize: 13,
fontWeight: 600,
cursor: opts?.disabled ? "default" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
flex: "0 0 auto",
whiteSpace: "nowrap",
}}
>
<Icon name={icon} size={18} />
{label}
</button>
);
};
return (
<AnimatePresence>
{open && (
<motion.div
key="scrim"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
onClick={onClose}
style={{ position: "fixed", inset: 0, zIndex: 600, background: "rgba(0,0,0,0.32)", display: "grid", placeItems: "center", padding: 24 }}
>
<motion.div
role="dialog"
aria-modal
aria-label={t("askAi", lang)}
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.96, opacity: 0 }}
transition={{ type: "spring", stiffness: 520, damping: 34, mass: 0.7 }}
onClick={(e) => e.stopPropagation()}
style={{
width: "min(100%, 620px)",
padding: 24,
borderRadius: 28,
background: p.surfaceContainerHigh,
color: p.onSurface,
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.10)",
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 22 }}>{t("askAi", lang)}</span>
<Beta p={p} />
<span style={{ flex: 1 }} />
{pill(copied === "link" ? "check" : "link", copied === "link" ? t("copied", lang) : t("shareLinkCopy", lang), copyLink, { title: t("shareLinkHint", lang) })}
</div>
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.55, color: p.onSurfaceVariant }}>{t("askAiHint", lang)}</p>
<textarea
value={idea}
onChange={(e) => onIdea(e.target.value)}
placeholder={t("askAiIdea", lang)}
rows={4}
autoFocus
spellCheck={false}
style={{
width: "100%",
padding: "10px 14px",
borderRadius: 14,
border: "none",
background: p.surface,
color: p.onSurface,
font: "inherit",
fontSize: 14,
lineHeight: 1.45,
outline: "none",
boxSizing: "border-box",
resize: "none",
}}
/>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* the left of the row: where to paste after a copy, or how to unlock drafting */}
<span style={{ flex: 1, minWidth: 0, display: "inline-flex", alignItems: "center", gap: 6, fontSize: 13, fontWeight: copied === "ask" ? 600 : 400, color: copied === "ask" ? p.primary : p.onSurfaceVariant }}>
{copied === "ask" ? (
<>
<Icon name="content_paste_go" size={18} />
{t("askAiPasted", lang)}
</>
) : !aiReady ? (
t("aiSetupHint", lang)
) : null}
</span>
<div style={{ display: "inline-flex", gap: 3, flex: "0 0 auto" }}>
{pill(copied === "ask" ? "check" : "content_copy", copied === "ask" ? t("copied", lang) : t("askAiCopy", lang), copyAsk, { corners: "left", title: t("askAiCopyTitle", lang) })}
{aiReady
? pill("auto_awesome", t("askAiGenerate", lang), () => onDraft(idea), { primary: true, disabled: !idea.trim(), corners: "right", title: t("askAiGenerateTitle", lang) })
: pill("key", t("aiSetup", lang), onSetupAi, { primary: true, corners: "right", title: t("aiSetupTitle", lang) })}
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
onClick={onClose}
title={t("closeBtn", lang)}
className="m3-press"
style={{ height: 40, padding: "0 16px", borderRadius: 20, border: "none", background: "transparent", color: p.primary, fontSize: 14, fontWeight: 600, cursor: "pointer" }}
>
{t("closeBtn", lang)}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}