diff --git a/.gitignore b/.gitignore index 829ecf68..0f1fe805 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .vscode +.idea dist # Turborepo cache diff --git a/examples/contained/.gitignore b/examples/contained/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/examples/contained/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/contained/README.md b/examples/contained/README.md new file mode 100644 index 00000000..c767aa54 --- /dev/null +++ b/examples/contained/README.md @@ -0,0 +1,3 @@ +# `webamp/contained` Example + +Example for Webamp fully contained into a DOM element. Uses [Vite](https://vitejs.dev/) for development and bundling. diff --git a/examples/contained/index.html b/examples/contained/index.html new file mode 100644 index 00000000..99441f43 --- /dev/null +++ b/examples/contained/index.html @@ -0,0 +1,13 @@ + + + + + + + Webamp (contained) + + +
+ + + diff --git a/examples/contained/package.json b/examples/contained/package.json new file mode 100644 index 00000000..62b308a5 --- /dev/null +++ b/examples/contained/package.json @@ -0,0 +1,18 @@ +{ + "name": "contained", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^7.0.4" + }, + "dependencies": { + "webamp": "^2.2.0" + } +} diff --git a/examples/contained/src/main.ts b/examples/contained/src/main.ts new file mode 100644 index 00000000..523b6e2f --- /dev/null +++ b/examples/contained/src/main.ts @@ -0,0 +1,36 @@ +import Webamp from "../../../packages/webamp/js/webamp"; + +const webamp = new Webamp({ + initialTracks: [ + { + metaData: { + artist: "DJ Mike Llama", + title: "Llama Whippin' Intro", + }, + // NOTE: Your audio file must be served from the same domain as your HTML + // file, or served with permissive CORS HTTP headers: + // https://docs.webamp.org/docs/guides/cors + url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3", + duration: 5.322286, + }, + ], + windowLayout: { + main: { position: { left: 0, top: 0 } }, + equalizer: { position: { left: 0, top: 116 } }, + playlist: { + position: { left: 0, top: 232 }, + size: { extraHeight: 4, extraWidth: 0 }, + }, + }, +}); + +// Container smaller than body +const container = document.getElementById("app"); +container!.style.position = "absolute"; +container!.style.left = "20px"; +container!.style.top = "20px"; +container!.style.right = "120px"; +container!.style.bottom = "120px"; +container!.style.backgroundColor = "lightyellow"; + +webamp.renderWhenReady(container!, true); diff --git a/examples/contained/src/vite-env.d.ts b/examples/contained/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/contained/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/contained/tsconfig.json b/examples/contained/tsconfig.json new file mode 100644 index 00000000..4f5edc24 --- /dev/null +++ b/examples/contained/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/packages/webamp/js/actionCreators/windows.ts b/packages/webamp/js/actionCreators/windows.ts index 2a4ce8a2..7072ee50 100644 --- a/packages/webamp/js/actionCreators/windows.ts +++ b/packages/webamp/js/actionCreators/windows.ts @@ -104,20 +104,27 @@ export function updateWindowPositions( return { type: "UPDATE_WINDOW_POSITIONS", positions, absolute }; } -export function centerWindowsInContainer(container: HTMLElement): Thunk { +export function centerWindowsInContainer( + container: HTMLElement, + contained?: false +): Thunk { return (dispatch, getState) => { if (!Selectors.getPositionsAreRelative(getState())) { return; } - const { left, top } = container.getBoundingClientRect(); + const { left, top } = contained + ? { left: 0, top: 0 } + : container.getBoundingClientRect(); const { scrollWidth: width, scrollHeight: height } = container; dispatch(centerWindows({ left, top, width, height })); }; } -export function centerWindowsInView(): Thunk { - const height = window.innerHeight; - const width = window.innerWidth; +export function centerWindowsInView(parentDomNode?: HTMLElement): Thunk { + const { width, height } = + parentDomNode === document.body || !parentDomNode + ? { width: window.innerWidth, height: window.innerHeight } + : Utils.getElementSize(parentDomNode); return centerWindows({ left: 0, top: 0, width, height }); } @@ -168,13 +175,16 @@ export function centerWindows({ left, top, width, height }: Box): Thunk { }; } -export function browserWindowSizeChanged(size: { - height: number; - width: number; -}): Thunk { +export function browserWindowSizeChanged( + size: { + height: number; + width: number; + }, + parentDomNode?: HTMLElement +): Thunk { return (dispatch: Dispatch) => { dispatch({ type: "BROWSER_WINDOW_SIZE_CHANGED", ...size }); - dispatch(ensureWindowsAreOnScreen()); + dispatch(ensureWindowsAreOnScreen(parentDomNode)); }; } @@ -234,13 +244,16 @@ export function setWindowLayout(layout?: WindowLayout): Thunk { }; } -export function ensureWindowsAreOnScreen(): Thunk { +export function ensureWindowsAreOnScreen(parentDomNode?: HTMLElement): Thunk { return (dispatch, getState) => { const state = getState(); const windowsInfo = Selectors.getWindowsInfo(state); const getOpen = Selectors.getWindowOpen(state); - const { height, width } = Utils.getWindowSize(); + const { height, width } = + parentDomNode === document.body || !parentDomNode + ? Utils.getWindowSize() + : Utils.getElementSize(parentDomNode); const bounding = Utils.calculateBoundingBox( windowsInfo.filter((w) => getOpen(w.key)) ); @@ -294,6 +307,6 @@ export function ensureWindowsAreOnScreen(): Thunk { // I give up. Just reset everything. dispatch(resetWindowSizes()); dispatch(stackWindows()); - dispatch(centerWindowsInView()); + dispatch(centerWindowsInView(parentDomNode)); }; } diff --git a/packages/webamp/js/components/App.tsx b/packages/webamp/js/components/App.tsx index 2a6926e9..31cba88b 100644 --- a/packages/webamp/js/components/App.tsx +++ b/packages/webamp/js/components/App.tsx @@ -87,7 +87,7 @@ export default function App({ webampNode.style.right = "0"; webampNode.style.bottom = "0"; webampNode.style.overflow = "hidden"; - browserWindowSizeChanged(Utils.getWindowSize()); + browserWindowSizeChanged(Utils.getWindowSize(), parentDomNode); webampNode.style.right = "auto"; webampNode.style.bottom = "auto"; webampNode.style.overflow = "visible"; @@ -100,7 +100,7 @@ export default function App({ return () => { window.removeEventListener("resize", handleWindowResize); }; - }, [browserWindowSizeChanged, webampNode]); + }, [parentDomNode, browserWindowSizeChanged, webampNode]); useEffect(() => { if (onMount != null) { @@ -151,7 +151,10 @@ export default function App({ } > - + , diff --git a/packages/webamp/js/components/WindowManager.tsx b/packages/webamp/js/components/WindowManager.tsx index 4f7418c1..b90a2e94 100644 --- a/packages/webamp/js/components/WindowManager.tsx +++ b/packages/webamp/js/components/WindowManager.tsx @@ -16,6 +16,7 @@ const abuts = (a: Box, b: Box) => { interface Props { windows: { [windowId: string]: ReactNode }; + parentDomNode: HTMLElement; } type DraggingState = { @@ -25,9 +26,12 @@ type DraggingState = { mouseStart: Point; }; -function useHandleMouseDown(propsWindows: { - [windowId: string]: ReactNode; -}): ( +function useHandleMouseDown( + propsWindows: { + [windowId: string]: ReactNode; + }, + parentDomNode: HTMLElement +): ( key: WindowId, e: React.MouseEvent | React.TouchEvent ) => void { @@ -69,7 +73,9 @@ function useHandleMouseDown(propsWindows: { const withinDiff = SnapUtils.snapWithinDiff( proposedBox, - browserWindowSize + parentDomNode === document.body || !parentDomNode + ? browserWindowSize + : Utils.getElementSize(parentDomNode) ); const finalDiff = SnapUtils.applyMultipleDiffs( @@ -102,7 +108,7 @@ function useHandleMouseDown(propsWindows: { window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("touchend", handleMouseUp); }; - }, [browserWindowSize, draggingState, updateWindowPositions]); + }, [parentDomNode, browserWindowSize, draggingState, updateWindowPositions]); // Mouse down handler return useCallback( @@ -149,10 +155,13 @@ function useHandleMouseDown(propsWindows: { ); } -export default function WindowManager({ windows: propsWindows }: Props) { +export default function WindowManager({ + windows: propsWindows, + parentDomNode, +}: Props) { const windowsInfo = useTypedSelector(Selectors.getWindowsInfo); const setFocusedWindow = useActionCreator(Actions.setFocusedWindow); - const handleMouseDown = useHandleMouseDown(propsWindows); + const handleMouseDown = useHandleMouseDown(propsWindows, parentDomNode); const windows = windowsInfo.filter((w) => propsWindows[w.key]); diff --git a/packages/webamp/js/utils.ts b/packages/webamp/js/utils.ts index 732528c8..22ef4dd2 100644 --- a/packages/webamp/js/utils.ts +++ b/packages/webamp/js/utils.ts @@ -370,6 +370,16 @@ export function findLastIndex(arr: T[], cb: (val: T) => boolean) { return -1; } +export function getElementSize(domNode: HTMLElement): { + width: number; + height: number; +} { + return { + width: Math.max(domNode.scrollWidth, domNode.offsetWidth), + height: Math.max(domNode.scrollHeight, domNode.offsetHeight), + }; +} + export function getWindowSize(): { width: number; height: number } { // Apparently this is crazy across browsers. return { diff --git a/packages/webamp/js/webampLazy.tsx b/packages/webamp/js/webampLazy.tsx index 2078e514..6eb6d2ed 100644 --- a/packages/webamp/js/webampLazy.tsx +++ b/packages/webamp/js/webampLazy.tsx @@ -479,12 +479,14 @@ class Webamp { * * Webamp is rendered into a new DOM node at the end of the tag with the id `#webamp`. * - * If a domNode is passed, Webamp will place itself in the center of that DOM node. + * A domNode must be passed, Webamp will place itself in the center of that DOM node. + * + * @param contained TRUE to render Webamp inside the passed DOM node, and to fully contain its position inside of it * * @returns A promise is returned which will resolve after the render is complete. */ - async renderWhenReady(node: HTMLElement): Promise { - this.store.dispatch(Actions.centerWindowsInContainer(node)); + async renderWhenReady(node: HTMLElement, contained?: false): Promise { + this.store.dispatch(Actions.centerWindowsInContainer(node, contained)); await this.skinIsLoaded(); if (this._disposable.disposed) { return; @@ -505,13 +507,17 @@ class Webamp { onMount = resolve; }); + if (contained && getComputedStyle(node)?.position === "static") { + node.style.position = "relative"; + } + this._root.render( );