Skip to content

Commit

Permalink
jupyter windowing: first proof of concept (not done!) showing how to …
Browse files Browse the repository at this point in the history
…maintain iframe state in the context of full on windowing! Yes, this does work.
  • Loading branch information
williamstein committed Apr 23, 2022
1 parent 3994609 commit 5defdf1
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 48 deletions.
75 changes: 53 additions & 22 deletions src/packages/frontend/jupyter/cell-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

declare const $: any;

import { useEffect, useCallback } from "react";
import { MutableRefObject, useEffect, useCallback, useMemo } from "react";
import { debounce } from "lodash";
import { delay } from "awaiting";
import * as immutable from "immutable";
Expand All @@ -21,6 +21,16 @@ import useNotebookFrameActions from "@cocalc/frontend/frame-editors/jupyter-edit
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import useVirtuosoScrollHook from "@cocalc/frontend/components/virtuoso-scroll-hook";

import { createContext, useContext } from "react";
interface IFrameContextType {
iframeDivRef?: MutableRefObject<any>;
iframeOnScrolls?: { [key: string]: () => void };
}
const IFrameContext = createContext<IFrameContextType>({});
export const useIFrameContext: () => IFrameContextType = () => {
return useContext(IFrameContext);
};

interface CellListProps {
actions?: JupyterActions; // if not defined, then everything read only
name?: string;
Expand Down Expand Up @@ -357,34 +367,55 @@ export const CellList: React.FC<CellListProps> = (props: CellListProps) => {
setTimeout(() => {
frameActions.current?.set_scrollTop(scrollState);
}, 0);
for (const key in iframeOnScrolls) {
iframeOnScrolls[key]();
}
},
}
: { disabled: true }
);

const iframeDivRef = useRef<any>(null);
const iframeOnScrolls = useMemo(() => {
return {};
}, []);
if (use_windowed_list) {
return (
<Virtuoso
ref={virtuosoRef}
style={{ fontSize: `${font_size}px`, height: "100%" }}
totalCount={cell_list.size}
itemContent={(index) => {
const key = cell_list.get(index);
if (key == null) return null;
const is_last: boolean = key === cell_list.get(-1);
return (
<div style={{ overflow: "hidden" }}>
{render_insert_cell(key, "above")}
{render_cell(key, false, index)}
{is_last ? render_insert_cell(key, "below") : undefined}
</div>
);
}}
rangeChanged={(visibleRange) => {
virtuosoRangeRef.current = visibleRange;
}}
{...virtuosoScroll}
/>
<IFrameContext.Provider value={{ iframeDivRef, iframeOnScrolls }}>
<Virtuoso
ref={virtuosoRef}
topItemCount={1}
style={{ fontSize: `${font_size}px`, height: "100%" }}
totalCount={cell_list.size}
itemContent={(index) => {
if (index == 0) {
return (
<div
ref={iframeDivRef}
style={{
height: "1px",
overflow: "hidden",
}}
></div>
);
}
const key = cell_list.get(index - 1);
if (key == null) return null;
const is_last: boolean = key === cell_list.get(-1);
return (
<div style={{ overflow: "hidden" }}>
{render_insert_cell(key, "above")}
{render_cell(key, false, index - 1)}
{is_last ? render_insert_cell(key, "below") : undefined}
</div>
);
}}
rangeChanged={(visibleRange) => {
virtuosoRangeRef.current = visibleRange;
}}
{...virtuosoScroll}
/>
</IFrameContext.Provider>
);
}

Expand Down
4 changes: 2 additions & 2 deletions src/packages/frontend/jupyter/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

/*
Efficient backend processing of iframe srcdoc's.
Efficient backend processing of iframe srcdoc and general text/html messages.
MOTIVATION: Sage jmol.
*/
Expand All @@ -19,7 +19,7 @@ export function is_likely_iframe(content: string): boolean {
}
content = content.slice(0, 100).trim().toLowerCase();
return (
misc.startswith(content, '<iframe srcdoc="') ||
misc.startswith(content, "<iframe") ||
content.indexOf("<!doctype html>") >= 0 ||
(content.indexOf("<html>") >= 0 && content.indexOf("<head>") >= 0) ||
// special case "altair" inline html -- https://github.com/sagemathinc/cocalc/issues/4468
Expand Down
102 changes: 102 additions & 0 deletions src/packages/frontend/jupyter/output-messages/cached-iframe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
It is completely impossible in modern times to move an iframe in the DOM without loosing state,
as explained here:
https://stackoverflow.com/questions/8318264/how-to-move-an-iframe-in-the-dom-without-losing-its-state
Thus, obviously, we need to hide/show the iframe at a particular place, and move it to
match a placeholder div. That's the only possible way to make this work generically.
TODO:
- [ ] tracking vertical position
- [ ] measure and resize to match content size.
- [ ] garbage collect; if you keep changing and evaluating a cell (say), then the iframes just pile up.
Need to go through and get rid of any that are no longer valid. Hard to know how to do that from
here, since unmounting isn't relevant.
*/

import { useCallback, useEffect, useMemo, useRef } from "react";
import { get_blob_url } from "../server-urls";
import { useIFrameContext } from "@cocalc/frontend/jupyter/cell-list";
import { delay } from "awaiting";
import useIsMountedRef from "@cocalc/frontend/app-framework/is-mounted-hook";

const HEIGHT = "400px";

interface Props {
sha1: string;
project_id: string;
cacheId: string;
}

export default function CachedIFrame({ cacheId, sha1, project_id }: Props) {
const divRef = useRef<any>(null);
const eltRef = useRef<any>(null);
const iframeContext = useIFrameContext();
const isMountedRef = useIsMountedRef();
const key = useMemo(() => {
return `${cacheId}-${sha1}`;
}, [cacheId, sha1]);

const position = useCallback(() => {
// make it so eltRef.current is exactly positioned on top of divRef.current using CSS
if (eltRef.current == null || divRef.current == null) return;
const eltRect = eltRef.current.getBoundingClientRect();
const divRect = divRef.current.getBoundingClientRect();
let deltaTop = divRect.top - eltRect.top;
if (deltaTop) {
if (eltRef.current.style.top) {
deltaTop += parseFloat(eltRef.current.style.top.slice(0, -2));
}
eltRef.current.style.top = `${deltaTop}px`;
}
let deltaLeft = divRect.left - eltRect.left;
if (deltaLeft) {
if (eltRef.current.style.left) {
deltaLeft += parseFloat(eltRef.current.style.left.slice(0, -2));
}
eltRef.current.style.left = `${deltaLeft}px`;
}
}, []);
window.x = position;

useEffect(() => {
if (divRef.current == null) return;
(async () => {
let holder = $(iframeContext.iframeDivRef?.current);
if (holder.length == 0) {
// when first mounting, we have to wait until next loop until the holder is rendered.
await delay(0);
if (!isMountedRef.current) return;
holder = $(iframeContext.iframeDivRef?.current);
}
if (holder.length == 0) return;
let elt = holder.find(`#${key}`);
if (elt.length == 0) {
elt = $(
`<iframe id="${key}" src="${get_blob_url(
project_id,
"html",
sha1
)}" style="border:0;overflow:hidden;width:100%;height:${HEIGHT};position:absolute"/>`
);
holder.append(elt);
}
eltRef.current = elt[0];
if (iframeContext.iframeOnScrolls != null) {
iframeContext.iframeOnScrolls[key] = position;
}
elt.show();
position();
})();

return () => {
delete iframeContext.iframeOnScrolls?.[key];
$(eltRef.current).hide();
};
}, [key]);

return <div ref={divRef} style={{ height: HEIGHT, width: "100%" }}></div>;
}
47 changes: 29 additions & 18 deletions src/packages/frontend/jupyter/output-messages/iframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,42 @@ Handle iframe output messages involving a src doc.
*/

import { delay } from "awaiting";
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import useIsMountedRef from "@cocalc/frontend/app-framework/is-mounted-hook";
import useCounter from "@cocalc/frontend/app-framework/counter-hook";
import { get_blob_url } from "../server-urls";
import CachedIFrame from "./cached-iframe";

// This impact loading the iframe data from the backend project (via the sha1 hash).
// Doing retries is useful, e.g., since the project might not be running.
const MAX_ATTEMPTS = 10;
const MAX_WAIT = 5000;
const BACKOFF = 1.3;

interface Props {
sha1: string;
project_id: string;
cacheId?: string;
}

const MAX_ATTEMPTS = 10;
const MAX_WAIT = 5000;
const BACKOFF = 1.3;

export const IFrame: React.FC<Props> = (props: Props) => {
const { sha1, project_id } = props;
export default function IFrame(props: Props) {
return props.cacheId == null ? (
<NonCachedIFrame {...props} />
) : ( // @ts-ignore
<CachedIFrame {...props} />
);
}

const { val: attempts, inc: inc_attempts } = useCounter();
const [failed, set_failed] = useState<boolean>(false);
const delay_ref = useRef<number>(500);
function NonCachedIFrame({ sha1, project_id }: Props) {
const { val: attempts, inc: incAttempts } = useCounter();
const [failed, setFailed] = useState<boolean>(false);
const delayRef = useRef<number>(500);
const isMountedRef = useIsMountedRef();
const iframe_ref = useRef(null);
const iframeRef = useRef(null);

useEffect(() => {
const elt: any = ReactDOM.findDOMNode(iframe_ref.current);
const elt: any = ReactDOM.findDOMNode(iframeRef.current);
if (elt == null) return;
elt.onload = function () {
elt.style.height = elt.contentWindow.document.body.scrollHeight + "px";
Expand All @@ -42,24 +52,25 @@ export const IFrame: React.FC<Props> = (props: Props) => {

async function load_error(): Promise<void> {
if (attempts >= MAX_ATTEMPTS) {
set_failed(true);
setFailed(true);
return;
}
await delay(delay_ref.current);
await delay(delayRef.current);
if (!isMountedRef.current) return;
delay_ref.current = Math.max(MAX_WAIT, delay_ref.current * BACKOFF);
inc_attempts();
delayRef.current = Math.max(MAX_WAIT, delayRef.current * BACKOFF);
incAttempts();
}

if (failed) {
return <div>Failed to load iframe contents</div>;
}

return (
<iframe
ref={iframe_ref}
ref={iframeRef}
src={get_blob_url(project_id, "html", sha1) + `&attempts=${attempts}`}
onError={load_error}
style={{ border: 0, width: "100%", minHeight: "500px" }}
/>
);
};
}
2 changes: 1 addition & 1 deletion src/packages/frontend/jupyter/output-messages/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ interface CellOutputMessageProps {
directory?: string;
actions?: JupyterActions; // optional - not needed by most messages
name?: string;
id?: string; // optional, and not usually needed either
id?: string; // optional, and not usually needed either; this is the id of the cell. It is needed for iframe + windowing.
trust?: boolean; // is notebook trusted by the user (if not won't eval javascript)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import register from "./register";
import { IFrame } from "../iframe";
import IFrame from "../iframe";

register("iframe", 7, ({ project_id, value }) => {
register("iframe", 7, ({ id, project_id, value }) => {
if (value == null || project_id == null) {
return <pre>iframe must specify project_id and sha1</pre>;
}
return <IFrame sha1={value} project_id={project_id} />;
return <IFrame cacheId={id} sha1={value} project_id={project_id} />;
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,4 @@ import "./text-plain";
import "./simple-markdown";
import "./iframe-html"; // we use this instead of html-ssr to safely support things like plotly or anything else that loads dangerous html.
import "./image";
import "./iframe";
import "./pdf";
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface DataProps {
message: Map<string, any>;
project_id?: string;
directory?: string;
// id?: string;
id?: string;
actions?: JupyterActions;
name?: string; // name of redux store...
trust?: boolean;
Expand All @@ -23,6 +23,7 @@ export interface HandlerProps {
actions?: JupyterActions;
name?: string;
trust?: boolean;
id?: string;
}

type Handler = React.FC<HandlerProps>;
Expand Down

0 comments on commit 5defdf1

Please sign in to comment.