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
81 changes: 79 additions & 2 deletions apps/web/src/features/canvas/AppMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) {
const [pagesOpen, setPagesOpen] = useState(false);
const [snapshotsOpen, setSnapshotsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [embedOpen, setEmbedOpen] = useState(false);
const [embedUrl, setEmbedUrl] = useState("");
const [embedError, setEmbedError] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [overIdx, setOverIdx] = useState<number | null>(null);
Expand Down Expand Up @@ -141,6 +144,17 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) {
}
}

function submitEmbed() {
const err = useAssetStore.getState().insertEmbed(embedUrl);
if (err) {
setEmbedError(err);
return;
}
setEmbedUrl("");
setEmbedError(null);
setEmbedOpen(false);
}

function commitDrop() {
if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) {
reorderPage(dragIdx, overIdx);
Expand Down Expand Up @@ -212,10 +226,20 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) {
/>
<MenuItem
icon="upload"
label="Import image or PDF"
label="Import image, PDF or audio"
disabled={!canImport}
onClick={() => run(() => fileRef.current?.click())}
/>
<MenuItem
icon="plus"
label="Embed YouTube / Google Drive…"
disabled={!canImport}
onClick={() => {
setMenuOpen(false);
setEmbedError(null);
setEmbedOpen(true);
}}
/>
<MenuItem
icon="download"
label={exporting ? "Exporting…" : "Export as PDF…"}
Expand Down Expand Up @@ -382,6 +406,59 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) {
</div>
</Popover>

{/* Embed-by-URL (YouTube / Google Drive) */}
<Popover
open={embedOpen}
onClose={() => setEmbedOpen(false)}
anchorRef={menuBtnRef}
placement="bottom"
className="menu-popover"
>
<div className="menu" style={{ padding: 12, minWidth: 280 }}>
<div className="menu__section-title">Embed a link</div>
<input
type="url"
value={embedUrl}
autoFocus
placeholder="Paste a YouTube or Google Drive URL"
onChange={(e) => {
setEmbedUrl(e.target.value);
if (embedError) setEmbedError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") submitEmbed();
}}
style={{
width: "100%",
boxSizing: "border-box",
padding: "8px 10px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,0.18)",
background: "rgba(0,0,0,0.25)",
color: "inherit",
outline: "none",
marginTop: 6,
}}
/>
{embedError && (
<div style={{ color: "#ff6b6b", fontSize: 12, marginTop: 6 }}>
{embedError}
</div>
)}
<div style={{ fontSize: 11, opacity: 0.6, marginTop: 6 }}>
Google Drive files must be shared “anyone with the link”.
</div>
<button
type="button"
className="menu__item"
style={{ justifyContent: "center", marginTop: 8 }}
onClick={submitEmbed}
>
<span className="menu__item-label">Add to board</span>
</button>
</div>
</Popover>

<SnapshotsPanel
open={snapshotsOpen}
onClose={() => setSnapshotsOpen(false)}
Expand All @@ -394,7 +471,7 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) {
<input
ref={fileRef}
type="file"
accept="image/*,application/pdf"
accept="image/*,application/pdf,audio/*"
multiple
style={{ display: "none" }}
onChange={(e) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/canvas/SelectionInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function shapeColor(s: YShape): string | undefined {
case "sticky":
return s.color;
case "asset":
case "embed":
return undefined;
}
}
Expand All @@ -38,6 +39,7 @@ function colorPatch(s: YShape, color: string): Partial<YShape> | null {
case "sticky":
return { color };
case "asset":
case "embed":
return null;
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/canvas/src/CanvasStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useShapeStore } from "./store/shapeStore";
import { useTextEditStore } from "./store/textEditStore";
import { useToolStore } from "./store/toolStore";
import { TextEditorOverlay } from "./TextEditorOverlay";
import { MediaOverlayLayer } from "./MediaOverlayLayer";
import { makeTool } from "./tools/registry";
import { boundsIntersect, shapeBounds } from "./tools/shapeOps";
import type { Tool, ToolContext, ToolEventPoint } from "./tools/types";
Expand Down Expand Up @@ -523,6 +524,11 @@ export function CanvasStage({
<OverlayLayer draft={draft} selectedShapes={selectedShapes} viewport={viewport} />
<PresenceLayer awareness={awareness} viewport={viewport} />
</Stage>
<MediaOverlayLayer
shapes={shapes}
selection={selection}
viewport={viewport}
/>
<TextEditorOverlay
viewport={viewport}
pageId={pageId}
Expand Down
110 changes: 110 additions & 0 deletions packages/canvas/src/MediaOverlayLayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { CSSProperties } from "react";
import type { YEmbed, YShape } from "@notux/types";
import { assetPublicUrl } from "./assets/storage";
import { useAssetStore } from "./store/assetStore";
import type { ViewportState } from "./viewport/Viewport";

interface Props {
shapes: YShape[];
selection: Set<string>;
viewport: ViewportState;
}

// HTML players floated over the Konva stage, one per `embed` shape. Konva's
// <canvas> can't host <audio>/<iframe>, so each player is an absolutely
// positioned DOM node tracking the shape through the viewport transform (same
// world→screen math as TextEditorOverlay).
//
// Pointer rule: the player is click-through (pointer-events:none) unless its
// card is selected. So a single click selects/drags the card via Konva, and a
// second click (now selected) reaches the controls. This keeps SelectTool and
// the Transformer working unchanged.
export function MediaOverlayLayer({ shapes, selection, viewport }: Props) {
const embeds = shapes.filter((s): s is YEmbed => s.kind === "embed");
if (embeds.length === 0) return null;

return (
<>
{embeds.map((shape) => (
<EmbedPlayer
key={shape.id}
shape={shape}
selected={selection.has(shape.id)}
viewport={viewport}
/>
))}
</>
);
}

function EmbedPlayer({
shape,
selected,
viewport,
}: {
shape: YEmbed;
selected: boolean;
viewport: ViewportState;
}) {
const left = shape.x * viewport.scale + viewport.x;
const top = shape.y * viewport.scale + viewport.y;
const width = shape.w * viewport.scale;
const height = shape.h * viewport.scale;

const wrap: CSSProperties = {
position: "absolute",
left,
top,
width,
height,
transform: shape.rot ? `rotate(${shape.rot}deg)` : undefined,
transformOrigin: "top left",
// Click-through until selected, so Konva drives select/drag first.
pointerEvents: selected ? "auto" : "none",
overflow: "hidden",
borderRadius: 10,
// Leave room so the EmbedRenderer card header (provider/title) stays visible
// for audio; the player sits in the lower portion of the card.
display: "flex",
alignItems: "flex-end",
boxSizing: "border-box",
zIndex: 5,
};

if (shape.embedType === "audio") {
const src = resolveAudioSrc(shape.assetId);
return (
<div style={wrap}>
{src ? (
<audio
controls
src={src}
style={{ width: "100%", display: "block" }}
/>
) : null}
</div>
);
}

// youtube / gdrive — iframe embed.
return (
<div style={{ ...wrap, alignItems: "stretch" }}>
{shape.url ? (
<iframe
src={shape.url}
title={shape.title}
style={{ width: "100%", height: "100%", border: "none", borderRadius: 10 }}
allow="autoplay; encrypted-media; picture-in-picture"
allowFullScreen
/>
) : null}
</div>
);
}

function resolveAudioSrc(assetId: string | undefined): string | null {
if (!assetId) return null;
const { _client, _boardId } = useAssetStore.getState();
if (!_client || !_boardId) return null;
return assetPublicUrl(_client, _boardId, assetId);
}
85 changes: 85 additions & 0 deletions packages/canvas/src/assets/embeds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// URL parsing for embeddable media resources (YouTube / Google Drive). Turns a
// pasted share/watch URL into the canonical iframe-embed URL stored on a YEmbed
// shape. Returns null when the URL isn't recognised so the UI can show an error.

export interface ParsedEmbed {
embedType: "youtube" | "gdrive";
// Canonical iframe src.
url: string;
}

// Accepts the common YouTube URL shapes:
// https://www.youtube.com/watch?v=<id>
// https://youtu.be/<id>
// https://www.youtube.com/embed/<id>
// https://www.youtube.com/shorts/<id>
// and (separately) Google Drive file links:
// https://drive.google.com/file/d/<id>/view?usp=sharing
// https://drive.google.com/open?id=<id>
// https://drive.google.com/uc?id=<id>
export function parseEmbedUrl(raw: string): ParsedEmbed | null {
const trimmed = raw.trim();
if (!trimmed) return null;

let u: URL;
try {
u = new URL(trimmed);
} catch {
return null;
}

const host = u.hostname.replace(/^www\./, "").toLowerCase();

// ----- YouTube -----------------------------------------------------------
if (host === "youtube.com" || host === "m.youtube.com" || host === "youtu.be") {
const id = youtubeId(u, host);
if (id) {
return {
embedType: "youtube",
url: `https://www.youtube.com/embed/${id}`,
};
}
return null;
}

// ----- Google Drive ------------------------------------------------------
if (host === "drive.google.com") {
const id = driveId(u);
if (id) {
return {
embedType: "gdrive",
url: `https://drive.google.com/file/d/${id}/preview`,
};
}
return null;
}

return null;
}

const YT_ID = /^[A-Za-z0-9_-]{11}$/;

function youtubeId(u: URL, host: string): string | null {
if (host === "youtu.be") {
const id = u.pathname.split("/").filter(Boolean)[0];
return id && YT_ID.test(id) ? id : null;
}
const v = u.searchParams.get("v");
if (v && YT_ID.test(v)) return v;
// /embed/<id> or /shorts/<id>
const segs = u.pathname.split("/").filter(Boolean);
if ((segs[0] === "embed" || segs[0] === "shorts") && segs[1] && YT_ID.test(segs[1])) {
return segs[1];
}
return null;
}

function driveId(u: URL): string | null {
// /file/d/<id>/...
const segs = u.pathname.split("/").filter(Boolean);
const dIdx = segs.indexOf("d");
if (segs[0] === "file" && dIdx >= 0 && segs[dIdx + 1]) return segs[dIdx + 1]!;
// ?id=<id> (open / uc)
const id = u.searchParams.get("id");
return id || null;
}
Loading
Loading