diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
index 1c8f1b19..c8057156 100644
--- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
@@ -36,6 +36,8 @@ import { ButtonPrimary } from "components/Buttons";
import { blockTextSize } from "src/utils/blockTextSize";
import { slugify } from "src/utils/slugify";
import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
+import { useIframeChannel } from "src/hooks/useIframeChannel";
+import { openPage as openPageAction } from "./postPageState";
export function PostContent({
blocks,
@@ -191,13 +193,10 @@ export let Block = ({
}
case PubLeafletBlocksIframe.isMain(b.block): {
return (
-
);
}
@@ -442,6 +441,33 @@ export let Block = ({
}
};
+function PublishedIframeBlock(props: {
+ url: string;
+ height?: number;
+ pageId?: string;
+}) {
+ let parentPage = props.pageId
+ ? { type: "doc" as const, id: props.pageId }
+ : undefined;
+ let { iframeRef } = useIframeChannel({
+ onOpen: (url) => {
+ openPageAction(parentPage, { type: "iframe", url });
+ },
+ });
+
+ return (
+
+ );
+}
+
function ListItem(props: {
index: number[];
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
index f622d03e..77da144a 100644
--- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
@@ -29,15 +29,23 @@ import {
type DocPage,
type ThreadPage,
type QuotesPage,
+ type IframePage,
getPageKey,
useOpenPages,
useInitializeOpenPages,
- openPage,
+ openPage as openPageAction,
closePage,
} from "./postPageState";
+import { IframePageView } from "components/Pages/IframePageView";
-export type { DocPage, ThreadPage, QuotesPage, OpenPage };
-export { getPageKey, useOpenPages, useInitializeOpenPages, openPage, closePage };
+export type { DocPage, ThreadPage, QuotesPage, IframePage, OpenPage };
+export {
+ getPageKey,
+ useOpenPages,
+ useInitializeOpenPages,
+ openPageAction as openPage,
+ closePage,
+};
// Shared props type for both page components
export type SharedPageProps = {
@@ -230,6 +238,27 @@ export function PostPages({
);
}
+ // Handle iframe pages
+ if (openPage.type === "iframe") {
+ return (
+
+
+ {
+ openPageAction(openPage, { type: "iframe", url });
+ }}
+ pageOptions={
+ closePage(openPage)}
+ hasPageBackground={hasPageBackground}
+ />
+ }
+ />
+
+ );
+ }
+
// Handle document pages
let page = pages.find(
(p) =>
diff --git a/app/lish/[did]/[publication]/[rkey]/postPageState.ts b/app/lish/[did]/[publication]/[rkey]/postPageState.ts
index 7553b9cd..e91c62a7 100644
--- a/app/lish/[did]/[publication]/[rkey]/postPageState.ts
+++ b/app/lish/[did]/[publication]/[rkey]/postPageState.ts
@@ -9,12 +9,14 @@ import { useEffect } from "react";
export type DocPage = { type: "doc"; id: string };
export type ThreadPage = { type: "thread"; uri: string };
export type QuotesPage = { type: "quotes"; uri: string };
-export type OpenPage = DocPage | ThreadPage | QuotesPage;
+export type IframePage = { type: "iframe"; url: string };
+export type OpenPage = DocPage | ThreadPage | QuotesPage | IframePage;
// Get a stable key for a page
export const getPageKey = (page: OpenPage): string => {
if (page.type === "doc") return page.id;
if (page.type === "quotes") return `quotes:${page.uri}`;
+ if (page.type === "iframe") return `iframe:${page.url}`;
return `thread:${page.uri}`;
};
diff --git a/components/Mention.tsx b/components/Mention.tsx
index 4dde4a5a..637baf1d 100644
--- a/components/Mention.tsx
+++ b/components/Mention.tsx
@@ -165,6 +165,8 @@ export function MentionAutocomplete(props: {
name: selectedSuggestion.name,
scope: selectedSuggestion.subscope.scope,
serviceName: scope.serviceName ?? scope.name,
+ scopedDid: scope.scopedDid,
+ scopedDidName: scope.scopedDidName,
});
}
} else if (e.key === "Enter") {
diff --git a/components/Pages/IframePageView.tsx b/components/Pages/IframePageView.tsx
new file mode 100644
index 00000000..3c9cbb6b
--- /dev/null
+++ b/components/Pages/IframePageView.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import React from "react";
+import { useIframeChannel } from "src/hooks/useIframeChannel";
+import { PageWrapper } from "./Page";
+
+export function IframePageView(props: {
+ url: string;
+ pageOptions?: React.ReactNode;
+ onOpen: (url: string) => void;
+}) {
+ let { iframeRef } = useIframeChannel({ onOpen: props.onOpen });
+
+ return (
+
+
+
+ );
+}
diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx
index 30f74696..2be44591 100644
--- a/components/Pages/Page.tsx
+++ b/components/Pages/Page.tsx
@@ -105,6 +105,8 @@ export const PageWrapper = (props: {
pageType: "canvas" | "doc";
drawerOpen: boolean | undefined;
fixedWidth?: boolean;
+ noBottomSpacer?: boolean;
+ overflow?: "scroll" | "hidden";
}) => {
const cardBorderHidden = useCardBorderHidden();
let { ref } = usePreserveScroll(props.id);
@@ -127,7 +129,7 @@ export const PageWrapper = (props: {
pageScrollWrapper
grow
shrink-0 snap-center
- overflow-y-scroll
+ ${props.overflow === "hidden" ? "overflow-hidden" : "overflow-y-scroll"}
${
!cardBorderHidden &&
`h-full border
@@ -152,7 +154,7 @@ export const PageWrapper = (props: {
`}
>
{props.children}
- {props.pageType === "doc" && }
+ {props.pageType === "doc" && !props.noBottomSpacer && }
{props.pageOptions}
diff --git a/components/Pages/index.tsx b/components/Pages/index.tsx
index c25dacf3..537a769e 100644
--- a/components/Pages/index.tsx
+++ b/components/Pages/index.tsx
@@ -1,7 +1,7 @@
"use client";
import React from "react";
-import { useUIState } from "src/useUIState";
+import { useUIState, getEditorPageKey } from "src/useUIState";
import { useSearchParams } from "next/navigation";
import { useEntity } from "src/replicache";
@@ -10,6 +10,10 @@ import { useCardBorderHidden } from "./useCardBorderHidden";
import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout";
import { LeafletSidebar } from "app/[leaflet_id]/Sidebar";
import { Page } from "./Page";
+import { IframePageView } from "./IframePageView";
+import { PageOptionButton } from "./PageOptions";
+import { CloseTiny } from "components/Icons/CloseTiny";
+import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
export function Pages(props: { rootPage: string }) {
let rootPage = useEntity(props.rootPage, "root/page")[0];
@@ -34,16 +38,59 @@ export function Pages(props: { rootPage: string }) {
)}
- {pages.map((page) => (
-
- {
- e.currentTarget === e.target && blurPage();
- }}
- />
-
-
- ))}
+ {pages.map((page) => {
+ let key = getEditorPageKey(page);
+ if (typeof page === "object") {
+ return (
+
+ {
+ e.currentTarget === e.target && blurPage();
+ }}
+ />
+ {
+ useUIState
+ .getState()
+ .openPage(page, { type: "iframe", url });
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ scrollIntoViewIfNeeded(
+ document.getElementById(`iframe-page-${url}`),
+ false,
+ "smooth",
+ 0.8,
+ );
+ });
+ });
+ }}
+ pageOptions={
+
+
+ useUIState.getState().closePage(page)
+ }
+ >
+
+
+
+ }
+ />
+
+ );
+ }
+ return (
+
+ {
+ e.currentTarget === e.target && blurPage();
+ }}
+ />
+
+
+ );
+ })}
{!fullPageScroll && (
{
diff --git a/components/RouteUIStateManger.tsx b/components/RouteUIStateManger.tsx
index b416158a..ee5e4ce6 100644
--- a/components/RouteUIStateManger.tsx
+++ b/components/RouteUIStateManger.tsx
@@ -1,9 +1,9 @@
"use client";
-import { useUIState } from "src/useUIState";
+import { useUIState, type EditorOpenPage } from "src/useUIState";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
-const routeOpenPages = new Map();
+const routeOpenPages = new Map();
let previousPathname = null as null | string;
export const RouteUIStateManager = () => {
const pathname = usePathname();
diff --git a/src/hooks/useIframeChannel.ts b/src/hooks/useIframeChannel.ts
new file mode 100644
index 00000000..8d526e97
--- /dev/null
+++ b/src/hooks/useIframeChannel.ts
@@ -0,0 +1,81 @@
+import { useCallback, useEffect, useRef } from "react";
+
+export function useIframeChannel(options: { onOpen: (url: string) => void }) {
+ let portRef = useRef(null);
+ let iframeElRef = useRef(null);
+ let onOpenRef = useRef(options.onOpen);
+ onOpenRef.current = options.onOpen;
+
+ let cleanup = useCallback(() => {
+ if (portRef.current) {
+ portRef.current.close();
+ portRef.current = null;
+ }
+ }, []);
+
+ let handleMessage = useCallback(
+ (event: MessageEvent) => {
+ let iframe = iframeElRef.current;
+ if (!iframe?.contentWindow) return;
+ if (event.source !== iframe.contentWindow) return;
+ if (event.data?.type !== "parts.page.connect") return;
+
+ let src = iframe.src;
+ console.log("[parts.page] connect request from", src);
+
+ cleanup();
+
+ let channel = new MessageChannel();
+ portRef.current = channel.port1;
+
+ channel.port1.onmessage = (e) => {
+ let data = e.data;
+ if (
+ !data ||
+ typeof data !== "object" ||
+ typeof data.command !== "string"
+ ) {
+ console.warn("[parts.page] invalid message from", src, data);
+ return;
+ }
+
+ console.log("[parts.page] command from", src, data);
+
+ switch (data.command) {
+ case "open": {
+ if (typeof data.url === "string") {
+ onOpenRef.current(data.url);
+ }
+ break;
+ }
+ }
+ };
+
+ console.log("[parts.page] channel established with", src);
+ iframe.contentWindow.postMessage({ type: "parts.page.channel" }, "*", [
+ channel.port2,
+ ]);
+ },
+ [cleanup],
+ );
+
+ useEffect(() => {
+ window.addEventListener("message", handleMessage);
+ return () => {
+ window.removeEventListener("message", handleMessage);
+ cleanup();
+ };
+ }, [handleMessage, cleanup]);
+
+ let iframeRef = useCallback(
+ (el: HTMLIFrameElement | null) => {
+ if (iframeElRef.current) {
+ cleanup();
+ }
+ iframeElRef.current = el;
+ },
+ [cleanup],
+ );
+
+ return { iframeRef };
+}
diff --git a/src/useUIState.ts b/src/useUIState.ts
index 713fe980..7fd0047a 100644
--- a/src/useUIState.ts
+++ b/src/useUIState.ts
@@ -3,6 +3,13 @@ import { create } from "zustand";
import { combine, createJSONStorage, persist } from "zustand/middleware";
type SelectedBlock = Pick;
+
+export type EditorIframePage = { type: "iframe"; url: string };
+export type EditorOpenPage = string | EditorIframePage;
+
+export const getEditorPageKey = (page: EditorOpenPage): string =>
+ typeof page === "string" ? page : `iframe:${page.url}`;
+
export const useUIState = create(
combine(
{
@@ -13,7 +20,7 @@ export const useUIState = create(
| { entityType: "footnote"; entityID: string; parent: string }
| null,
foldedBlocks: [] as string[],
- openPages: [] as string[],
+ openPages: [] as EditorOpenPage[],
selectedBlocks: [] as SelectedBlock[],
openPopover: null as string | null,
},
@@ -30,9 +37,12 @@ export const useUIState = create(
};
});
},
- openPage: (parent: string, page: string) =>
+ openPage: (parent: EditorOpenPage, page: EditorOpenPage) =>
set((state) => {
- let parentPosition = state.openPages.findIndex((s) => s == parent);
+ let parentKey = getEditorPageKey(parent);
+ let parentPosition = state.openPages.findIndex(
+ (s) => getEditorPageKey(s) === parentKey,
+ );
return {
openPages:
parentPosition === -1
@@ -40,10 +50,15 @@ export const useUIState = create(
: [...state.openPages.slice(0, parentPosition + 1), page],
};
}),
- closePage: (pages: string | string[]) =>
- set((s) => ({
- openPages: s.openPages.filter((c) => ![pages].flat().includes(c)),
- })),
+ closePage: (pages: EditorOpenPage | EditorOpenPage[]) =>
+ set((s) => {
+ let keys = [pages].flat().map(getEditorPageKey);
+ return {
+ openPages: s.openPages.filter(
+ (c) => !keys.includes(getEditorPageKey(c)),
+ ),
+ };
+ }),
setFocusedBlock: (
b:
| { entityType: "page"; entityID: string }
diff --git a/src/utils/focusPage.ts b/src/utils/focusPage.ts
index cc68de6f..2b330c0b 100644
--- a/src/utils/focusPage.ts
+++ b/src/utils/focusPage.ts
@@ -21,17 +21,19 @@ export async function focusPage(
},
}));
- setTimeout(async () => {
- //scroll to page
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ scrollIntoViewIfNeeded(
+ document.getElementById(elementId.page(pageID).container),
+ false,
+ "smooth",
+ 0.8,
+ );
+ });
+ });
- scrollIntoViewIfNeeded(
- document.getElementById(elementId.page(pageID).container),
- false,
- "smooth",
- );
-
- // if we asked that the function focus the first block, focus the first block
- if (focusFirstBlock === "focusFirstBlock") {
+ if (focusFirstBlock === "focusFirstBlock") {
+ setTimeout(async () => {
let firstBlock = await rep.query(async (tx) => {
let type = await scanIndex(tx).eav(pageID, "page/type");
let blocks = await scanIndex(tx).eav(
@@ -68,6 +70,6 @@ export async function focusPage(
focusBlock(firstBlock, { type: "start" });
}, 500);
}
- }
- }, 50);
+ }, 50);
+ }
}
diff --git a/src/utils/scrollIntoView.ts b/src/utils/scrollIntoView.ts
index ce2dd4de..27af1536 100644
--- a/src/utils/scrollIntoView.ts
+++ b/src/utils/scrollIntoView.ts
@@ -11,7 +11,7 @@ export function scrollIntoView(
// pages from within other pages.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
- scrollIntoViewIfNeeded(element, false, "smooth");
+ scrollIntoViewIfNeeded(element, false, "smooth", threshold);
});
});
}
diff --git a/src/utils/scrollIntoViewIfNeeded.ts b/src/utils/scrollIntoViewIfNeeded.ts
index 583d762d..74db1c23 100644
--- a/src/utils/scrollIntoViewIfNeeded.ts
+++ b/src/utils/scrollIntoViewIfNeeded.ts
@@ -2,25 +2,29 @@ export function scrollIntoViewIfNeeded(
el: Element | null,
centerIfNeeded: boolean = true,
behavior?: ScrollBehavior,
+ threshold: number = 1,
) {
if (!el) {
return;
}
- let observer = new IntersectionObserver(function ([entry]) {
- const ratio = entry.intersectionRatio;
+ let observer = new IntersectionObserver(
+ function ([entry]) {
+ const ratio = entry.intersectionRatio;
- if (ratio < 1) {
- let place =
- ratio <= 0 && centerIfNeeded
- ? ("center" as const)
- : ("nearest" as const);
- el.scrollIntoView({
- block: place,
- inline: place,
- behavior: behavior ? behavior : "auto",
- });
- }
- observer.disconnect();
- });
+ if (ratio < threshold) {
+ let place =
+ ratio <= 0 && centerIfNeeded
+ ? ("center" as const)
+ : ("nearest" as const);
+ el.scrollIntoView({
+ block: place,
+ inline: place,
+ behavior: behavior ? behavior : "auto",
+ });
+ }
+ observer.disconnect();
+ },
+ { threshold: [0, threshold] },
+ );
observer.observe(el);
}