├── .github └── workflows │ ├── build-site.yml │ └── deploy.yml ├── .gitignore ├── CNAME ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── packages ├── common │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── vite.config.ts ├── playhtml │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── elements.ts │ │ ├── init.ts │ │ ├── main.ts │ │ ├── style.scss │ │ ├── utils.ts │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts └── react │ ├── CHANGELOG.md │ ├── README.md │ ├── example.tsx │ ├── examples │ ├── App.tsx │ ├── Confetti.tsx │ ├── LiveChat.scss │ ├── LiveChat.tsx │ ├── OnlineIndicator.tsx │ ├── Reaction.tsx │ ├── SharedColor.tsx │ ├── SharedLamp.tsx │ ├── SharedSound.tsx │ ├── ToggleSquare.tsx │ ├── ViewCount.tsx │ ├── VisitorCount.tsx │ ├── package.json │ └── resizable.tsx │ ├── package.json │ ├── src │ ├── PlayProvider.tsx │ ├── __tests__ │ │ ├── PlayProvider.test.tsx │ │ └── setup.ts │ ├── elements.tsx │ ├── hooks │ │ └── useLocation.ts │ ├── index.tsx │ ├── playhtml-singleton.ts │ └── utils.tsx │ ├── tsconfig.json │ ├── vite.config.ts │ ├── vitest.config.ts │ └── yarn.lock ├── partykit.json ├── partykit └── party.ts ├── tsconfig.json ├── vite.config.site.ts ├── website ├── base.scss ├── candles.html ├── events │ ├── events.scss │ ├── events.tsx │ ├── gray-area │ │ ├── gray-area.scss │ │ ├── gray-area.tsx │ │ └── index.html │ ├── if-then │ │ ├── if-then.tsx │ │ └── index.html │ └── walking-together │ │ ├── index.html │ │ ├── spec.md │ │ ├── walking-together.scss │ │ └── walking-together.tsx ├── experiments │ ├── 3 │ │ └── index.html │ ├── 4 │ │ ├── 4.scss │ │ ├── 4.tsx │ │ └── index.html │ ├── index.html │ ├── index.tsx │ ├── one │ │ ├── index.html │ │ ├── one.scss │ │ └── one.tsx │ └── two │ │ ├── index.html │ │ ├── two.scss │ │ └── two.tsx ├── fridge.html ├── fridge.scss ├── fridge.tsx ├── home.scss ├── hooks │ └── useStickyState.ts ├── index.html ├── index.tsx ├── playground.html ├── playground.scss ├── playground.ts ├── public │ ├── candle-gif.gif │ ├── candle-off.png │ ├── icon.png │ ├── lamp-on.m4a │ ├── lamps │ │ ├── Akari-1A.png │ │ ├── Akari-1AD.png │ │ ├── Akari-1AG.png │ │ ├── Akari-1AR.png │ │ ├── Akari-1AS.png │ │ ├── Akari-1AT.png │ │ ├── Akari-1AV.png │ │ ├── Akari-1AY.png │ │ ├── Akari-1N.png │ │ └── Akari-1P.png │ ├── noguchi-akari-a1.png │ ├── noguchi-hanging-lamp.png │ ├── playhtml-can-play.png │ ├── playhtml-can-spin.png │ ├── playhtml-can-toggle.png │ ├── playhtml-candles.png │ ├── playhtml-experiment-01.png │ ├── playhtml-experiment-02.png │ ├── playhtml-experiment-04.png │ ├── playhtml-fridge.png │ ├── playhtml-logo.png │ ├── playhtml-sign.png │ ├── playhtml-story.png │ ├── playhtml.png │ └── under-construction-website.gif ├── story.html ├── story.ts ├── test │ ├── react-test.html │ └── react-test.tsx └── useLocation.ts └── yarn.lock /.github/workflows/build-site.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Build site to ensure no errors. 3 | 4 | on: 5 | pull_request: 6 | types: 7 | - opened 8 | - edited 9 | - synchronize 10 | - reopened 11 | workflow_call: 12 | 13 | # Allow one concurrent deployment 14 | concurrency: 15 | group: "build" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | - name: Set up Node 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 18 28 | cache: "npm" 29 | - name: Install dependencies 30 | run: yarn 31 | # This is only needed because we import locally to test rather than importing from a CDN 32 | - name: Build packages 33 | run: yarn run build-packages 34 | - name: Build 35 | run: yarn run build-site 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Set up Node 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 18 37 | cache: "npm" 38 | - name: Install dependencies 39 | run: yarn 40 | # This is only needed because we import locally to test rather than importing from a CDN 41 | - name: Build packages 42 | run: yarn run build-packages 43 | - name: Build 44 | run: yarn run build-site 45 | - name: Setup Pages 46 | uses: actions/configure-pages@v3 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | with: 50 | # Upload dist repository 51 | path: "./site-dist" 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | site 13 | site-dist 14 | dist-ssr 15 | *.local 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | # dev persisted state 29 | .partykit 30 | 31 | # secrets 32 | .env 33 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | playhtml.fun 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # playhtml contribution guidelines 2 | 3 | ## New Capabilities `can-...` 4 | 5 | `playhtml` is designed to be a collective library of magical capabilities that anyone can attach to arbitrary HTML elements. 6 | 7 | If you have an idea for a new capability, please first ensure that there is not a duplicate existing one in the current library (see [`TagType`](https://github.com/spencerc99/playhtml/blob/main/src/types.ts#L100)). If it doesn't exist please make a proposal for the capability you would like to add by opening an issue with the `new-capability` label. 8 | 9 | To contribute your capability, you are also welcome to make a PR with your addition to `elements.ts` (see [sample PR](https://github.com/spencerc99/playhtml/pull/10/files#diff-37bc0716e9726d7764d49fcc1b08ca0eb3f52170af06f8a49504b47e33ae09d2R327-R383)). 10 | 11 | --- 12 | 13 | Outside of contributing new capabilities, feel free to submit any issues or 14 | PRs for bugs or improvements to the core of the library. 15 | 16 | ## Contributing to the core library 17 | 18 | There is future work I'm planning on getting to in the Issues section. If you have other ideas, please feel free to open an issue to discuss. Would love contributions from the community for any bugs or feature requests :) 19 | 20 | ### Linking local packages for development 21 | 22 | you'll need to link to local versions to test changes in `common` for use in `playhtml` or changes in `playhtml` for use in `react`. To do this, you can run the following commands: 23 | 24 | ```bash 25 | cd packages/common 26 | yarn link 27 | cd ../playhtml # or other package name.. 28 | yarn link @playhtml/common 29 | ``` 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Spencer Chang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playhtml-root", 3 | "private": true, 4 | "license": "MIT", 5 | "workspaces": [ 6 | "packages/playhtml", 7 | "packages/react", 8 | "packages/common" 9 | ], 10 | "scripts": { 11 | "dev": "vite --config vite.config.site.ts", 12 | "dev-server": "npx partykit dev partykit/party.ts", 13 | "deploy-server": "npx partykit deploy", 14 | "build-site": "vite build website --config vite.config.site.ts", 15 | "build-packages": "for dir in packages/*; do (cd \"$dir\" && yarn run build); done" 16 | }, 17 | "devDependencies": { 18 | "@cloudflare/workers-types": "^4.20230518.0", 19 | "@types/canvas-confetti": "^1.6.4", 20 | "@types/node": "^20.3.3", 21 | "@types/randomcolor": "^0.5.9", 22 | "@types/react": "^18.2.48", 23 | "@types/react-is": "^19.0.0", 24 | "@vitejs/plugin-react": "^4.2.1", 25 | "glob": "^10.3.10", 26 | "sass": "^1.62.1", 27 | "typescript": "^5.0.2", 28 | "vite": "^4.3.9", 29 | "vite-plugin-mpa": "^1.2.0" 30 | }, 31 | "dependencies": { 32 | "@playhtml/react": "beta", 33 | "@supabase/supabase-js": "^2.45.1", 34 | "canvas-confetti": "^1.9.2", 35 | "partykit": "0.0.108", 36 | "profane-words": "^1.5.11", 37 | "randomcolor": "^0.6.2", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "y-partykit": "0.0.31", 41 | "yjs": "13.6.18" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@playhtml/common", 3 | "description": "Common types for playhtml packages", 4 | "version": "0.0.14", 5 | "license": "MIT", 6 | "type": "module", 7 | "author": "Spencer Chang ", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/spencerc99/playhtml/packages/common.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/spencerc99/playhtml/issues" 14 | }, 15 | "main": "./dist/playhtml-common.es.js", 16 | "types": "./dist/main.d.ts", 17 | "module": "./dist/playhtml-common.es.js", 18 | "files": [ 19 | "dist" 20 | ], 21 | "exports": { 22 | ".": { 23 | "types": "./dist/main.d.ts", 24 | "import": "./dist/playhtml-common.es.js", 25 | "require": "./dist/playhtml-common.umd.js" 26 | } 27 | }, 28 | "publishConfig": { 29 | "access": "public" 30 | }, 31 | "scripts": { 32 | "build": "tsc && vite build", 33 | "set-publishing-config": "yarn config set version-tag-prefix '@playhtml/common-v' && yarn config set version-git-message '@playhtml/common-v%s'", 34 | "publish-npm": "yarn run build && npm run set-publishing-config && yarn publish" 35 | }, 36 | "devDependencies": { 37 | "typescript": "^5.0.2", 38 | "vite": "^4.3.9", 39 | "vite-plugin-dts": "^3.0.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/common/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | plugins: [dts({ rollupTypes: true })], 7 | build: { 8 | lib: { 9 | entry: path.resolve(__dirname, "src/index.ts"), 10 | name: "playhtml-common", 11 | fileName: (format) => `playhtml-common.${format}.js`, 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/playhtml/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | The format is based on Keep a Changelog and this project adheres to Semantic Versioning. 4 | 5 | ## 2.1.6 - 2024-04-17 6 | 7 | - fix bug with native image dragging conflicting with playhtml draggable elements. 8 | 9 | ## 2.1.2 - 2024-01-30 10 | 11 | - align dependencies 12 | 13 | ## 2.1.1 - 2024-01-30 14 | 15 | - added an `init.js` file export which can be imported to auto-initialize with default settings. Designed to be simplest way to get started with playhtml. 16 | 17 | ## 2.1.0 - 2024-01-27 18 | 19 | - **NEW FEATURE** Added eventing support for imperative logic like showing confetti whenever someone clicks a button which don't depend on a reacting to a data value changing. See the README under "eventing" for more details on how to set this up. 20 | - **BREAKING CHANGE** Changed the hash function used to generate element ids to be a stable length for long-term scalability. This will cause all elements without an `id` to be re-created to lose any persistent historical data. This was done to avoid duplicates and to swap to using a standard-length hash function (SHA-1). We still recommend you setting a unique `id` for each element to avoid any potential duplicates in the future, and using `selectorId` will not be affected by this change. 21 | 22 | ## 2.0.16 - 2024-01-04 23 | 24 | - **BREAKING CHANGE** deprecated using non-object values as `defaultData` for elements. If you were using a single value before, instead, use an object with a `value` key. e.g. `defaultData: { value: "my value" }`. This allows for easier extension of the data in the future. 25 | - **BREAKING CHANGE** deprecated `playhtml.init()` automatically being called to avoid side-effects upon import. This has been replaced with a new `init` file that you can directly import if you'd like to auto-initialize without any settings. See the README for more details. 26 | - exported `setupPlayElements` to call to look for any new elements to initialize 27 | 28 | ## 2.0.7 - 2023-10-02 29 | 30 | - upgrading y-partykit and yjs to latest for improved performance 31 | 32 | ## 2.0.5 - 2023-09-11 33 | 34 | - fixed an error with setting up elements before the provider was synced which lead to incorrect initial element states that didn't sync. 35 | - Removed the `firstSetup` export accordingly to allow for optimistically setting up elements even before `playhtml` is initialized. 36 | - Added `removePlayElement` to handle removing upon unmounting or removal of an element from the DOM to clear up the state. 37 | 38 | ## 2.0.4 - 2023-09-07 39 | 40 | - added @playhtml/react library 41 | - added `firstSetup` export from playhtml for raising error if it hasn't been initialized. 42 | - cleaned up exports 43 | 44 | ## 2.0.2 - 2023-08-23 45 | 46 | - handle deprecated import version by using a timeout. This adds a significant delay to the initialization of any client using the old method and logs a warning. 47 | 48 | ## 2.0.0 - 2023-08-23 49 | 50 | - **BREAKING CHANGE**: Changed the initializing of playhtml to be an explicit call of `playhtml.init()` from just a normal import. You can still use the old code if you pin the import to any version 1.3.1 (e.g. use `https://unpkg.com/playhtml@1.3.1` as the import source). 51 | 52 | **OLD CODE:** 53 | 54 | ```html 55 | 56 | 57 | ``` 58 | 59 | **NEW CODE:** 60 | 61 | ```html 62 | 71 | 72 | ``` 73 | 74 | This change allows for more flexible use of the package, including specifying a partykit host and room. 75 | 76 | - was accidentally importing all my files for the website into the package, blowing it up to 4MB. I've fixed this and compressed down the `.d.ts` types file to just what is needed, so the package is down to 360KB. It should load much faster on websites now :) 77 | 78 | ## 1.3.1 - 2023-08-09 79 | 80 | - Removed unused code and consolidated types in `types.ts` 81 | 82 | ## 1.3.0 - 2023-08-07 83 | 84 | - Added support for `can-duplicate` capability to duplicate elements. Make factories for playhtml elements!! 85 | 86 | ## 1.2.0 - 2023-08-03 87 | 88 | - Added support for yjs's `awareness` protocol to handle synced data that shouldn't be persisted. 89 | -------------------------------------------------------------------------------- /packages/playhtml/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /packages/playhtml/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playhtml", 3 | "title": "playhtml", 4 | "description": "Create interactive, collaborative html elements with a single attribute", 5 | "version": "2.1.12", 6 | "license": "MIT", 7 | "type": "module", 8 | "keywords": [ 9 | "html", 10 | "collaboration", 11 | "fun", 12 | "real-time", 13 | "persistence", 14 | "html energy" 15 | ], 16 | "author": { 17 | "name": "Spencer Chang", 18 | "email": "spencerc99@gmail.com" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "github:spencerc99/playhtml", 23 | "directory": "packages/playhtml" 24 | }, 25 | "funding": { 26 | "type": "github", 27 | "url": "https://github.com/sponsors/spencerc99" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/spencerc99/playhtml/issues" 31 | }, 32 | "main": "./dist/playhtml.es.js", 33 | "types": "./dist/main.d.ts", 34 | "module": "./dist/playhtml.es.js", 35 | "homepage": "https://playhtml.fun", 36 | "files": [ 37 | "dist" 38 | ], 39 | "exports": { 40 | ".": { 41 | "types": "./dist/main.d.ts", 42 | "import": "./dist/playhtml.es.js" 43 | }, 44 | "./dist/style.css": { 45 | "import": "./dist/style.css", 46 | "require": "./dist/style.css" 47 | } 48 | }, 49 | "scripts": { 50 | "build": "tsc && vite build", 51 | "set-publishing-config": "yarn config set version-tag-prefix 'playhtml-v' && yarn config set version-git-message 'playhtml-v%s'", 52 | "publish-npm": "(yarn run build && npm run set-publishing-config && rm README.md && cp ../../README.md . && yarn publish && yarn run cleanup) || (yarn run cleanup)", 53 | "cleanup": "rm README.md && ln -s ../../README.md ." 54 | }, 55 | "devDependencies": { 56 | "sass": "^1.62.1", 57 | "typescript": "^5.0.2", 58 | "vite": "^4.3.9", 59 | "vite-plugin-dts": "^3.0.3" 60 | }, 61 | "dependencies": { 62 | "@playhtml/common": "0.0.14", 63 | "y-indexeddb": "^9.0.11", 64 | "y-partykit": "^0.0.31", 65 | "yjs": "13.6.18" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/playhtml/src/elements.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { 3 | ElementAwarenessEventHandlerData, 4 | ElementData, 5 | ElementEventHandlerData, 6 | ElementSetupData, 7 | ModifierKey, 8 | } from "@playhtml/common"; 9 | 10 | // @ts-ignore 11 | const debounce = (fn: Function, ms = 300) => { 12 | let timeoutId: ReturnType; 13 | return function (this: any, ...args: any[]) { 14 | clearTimeout(timeoutId); 15 | timeoutId = setTimeout(() => fn.apply(this, args), ms); 16 | }; 17 | }; 18 | 19 | // TODO: turn this into just an extension of HTMLElement and initialize all the methods / do all the state tracking 20 | // on the element itself?? 21 | export class ElementHandler { 22 | defaultData: T; 23 | localData: U; 24 | awareness: V[] = []; 25 | selfAwareness?: V; 26 | element: HTMLElement; 27 | _data: T; 28 | onChange: (data: T) => void; 29 | onAwarenessChange: (data: V) => void; 30 | debouncedOnChange: (data: T) => void; 31 | resetShortcut?: ModifierKey; 32 | // TODO: change this to receive the delta instead of the whole data object so you don't have to maintain 33 | // internal state for expressing the delta. 34 | updateElement: (data: ElementEventHandlerData) => void; 35 | updateElementAwareness?: ( 36 | data: ElementAwarenessEventHandlerData 37 | ) => void; 38 | triggerAwarenessUpdate?: () => void; 39 | 40 | // event handlers 41 | onClick?: ( 42 | e: MouseEvent, 43 | eventData: ElementEventHandlerData 44 | ) => void; 45 | onDrag?: ( 46 | e: MouseEvent | TouchEvent, 47 | eventData: ElementEventHandlerData 48 | ) => void; 49 | onDragStart?: ( 50 | e: MouseEvent | TouchEvent, 51 | eventData: ElementEventHandlerData 52 | ) => void; 53 | 54 | constructor(elementData: ElementData) { 55 | const { 56 | element, 57 | onChange, 58 | onAwarenessChange, 59 | defaultData, 60 | defaultLocalData, 61 | myDefaultAwareness, 62 | data, 63 | awareness: awarenessData, 64 | updateElement, 65 | updateElementAwareness, 66 | onMount, 67 | debounceMs, 68 | triggerAwarenessUpdate, 69 | } = elementData; 70 | // console.log("🔨 constructing ", element.id); 71 | this.element = element; 72 | this.defaultData = 73 | defaultData instanceof Function ? defaultData(element) : defaultData; 74 | this.localData = 75 | defaultLocalData instanceof Function 76 | ? defaultLocalData(element) 77 | : defaultLocalData; 78 | this.triggerAwarenessUpdate = triggerAwarenessUpdate; 79 | this.onChange = onChange; 80 | this.debouncedOnChange = debounce(this.onChange, debounceMs); 81 | this.onAwarenessChange = onAwarenessChange; 82 | this.updateElement = updateElement; 83 | this.updateElementAwareness = updateElementAwareness; 84 | const initialData = data === undefined ? this.defaultData : data; 85 | 86 | if (awarenessData !== undefined) { 87 | this.__awareness = awarenessData; 88 | } 89 | const myInitialAwareness = 90 | myDefaultAwareness instanceof Function 91 | ? myDefaultAwareness(element) 92 | : myDefaultAwareness; 93 | if (myInitialAwareness !== undefined) { 94 | this.setMyAwareness(myInitialAwareness); 95 | } 96 | // Needed to get around the typescript error even though it is assigned in __data. 97 | this._data = initialData; 98 | this.__data = initialData; 99 | 100 | this.reinitializeElementData(elementData); 101 | 102 | if (onMount) { 103 | onMount(this.getSetupData()); 104 | } 105 | } 106 | 107 | reinitializeElementData({ 108 | element, 109 | onChange, 110 | onAwarenessChange, 111 | updateElement, 112 | updateElementAwareness, 113 | onClick, 114 | onDrag, 115 | onDragStart, 116 | resetShortcut, 117 | debounceMs, 118 | triggerAwarenessUpdate, 119 | }: ElementData) { 120 | this.triggerAwarenessUpdate = triggerAwarenessUpdate; 121 | this.onChange = onChange; 122 | this.debouncedOnChange = debounce(this.onChange, debounceMs); 123 | this.onAwarenessChange = onAwarenessChange; 124 | this.updateElement = updateElement; 125 | this.updateElementAwareness = updateElementAwareness; 126 | 127 | // Handle all the event handlers 128 | if (onClick && !this.onClick) { 129 | element.addEventListener("click", (e) => { 130 | this.onClick?.(e, this.getEventHandlerData()); 131 | }); 132 | } 133 | this.onClick = onClick; 134 | if (onDrag && !this.onDrag) { 135 | element.addEventListener("touchstart", (e) => { 136 | // To prevent scrolling the page while dragging 137 | e.preventDefault(); 138 | element.classList.add("cursordown"); 139 | 140 | // Need to be able to not persist everything in the data, causing some lag. 141 | this.onDragStart?.(e, this.getEventHandlerData()); 142 | 143 | const onMove = (e: TouchEvent) => { 144 | e.preventDefault(); 145 | this.onDrag?.(e, this.getEventHandlerData()); 146 | }; 147 | const onDragStop = (e: TouchEvent) => { 148 | element.classList.remove("cursordown"); 149 | document.removeEventListener("touchmove", onMove); 150 | document.removeEventListener("touchend", onDragStop); 151 | }; 152 | document.addEventListener("touchmove", onMove); 153 | document.addEventListener("touchend", onDragStop); 154 | }); 155 | element.addEventListener("mousedown", (e) => { 156 | // To prevent dragging images behavior conflicting. 157 | e.preventDefault(); 158 | // Need to be able to not persist everything in the data, causing some lag. 159 | this.onDragStart?.(e, this.getEventHandlerData()); 160 | element.classList.add("cursordown"); 161 | 162 | const onMouseMove = (e: MouseEvent) => { 163 | e.preventDefault(); 164 | this.onDrag?.(e, this.getEventHandlerData()); 165 | }; 166 | const onMouseUp = (e: MouseEvent) => { 167 | element.classList.remove("cursordown"); 168 | document.removeEventListener("mousemove", onMouseMove); 169 | document.removeEventListener("mouseup", onMouseUp); 170 | }; 171 | document.addEventListener("mousemove", onMouseMove); 172 | document.addEventListener("mouseup", onMouseUp); 173 | }); 174 | } 175 | this.onDrag = onDrag; 176 | this.onDragStart = onDragStart; 177 | 178 | // Handle advanced settings 179 | if (resetShortcut && !this.resetShortcut) { 180 | // @ts-ignore 181 | element.reset = this.reset; 182 | 183 | element.addEventListener("click", (e) => { 184 | switch (this.resetShortcut) { 185 | case "ctrlKey": 186 | if (!e.ctrlKey) { 187 | return; 188 | } 189 | break; 190 | case "altKey": 191 | if (!e.altKey) { 192 | return; 193 | } 194 | break; 195 | case "shiftKey": 196 | if (!e.shiftKey) { 197 | return; 198 | } 199 | break; 200 | case "metaKey": 201 | if (!e.metaKey) { 202 | return; 203 | } 204 | break; 205 | default: 206 | return; 207 | } 208 | this.reset(); 209 | e.preventDefault(); 210 | e.stopPropagation(); 211 | }); 212 | } 213 | this.resetShortcut = resetShortcut; 214 | } 215 | 216 | get data(): T { 217 | return this._data; 218 | } 219 | 220 | setLocalData(localData: U): void { 221 | this.localData = localData; 222 | } 223 | 224 | /** 225 | * // PRIVATE USE ONLY \\ 226 | * 227 | * Updates the internal state with the given data and handles all the downstream effects. Should only be used by the sync code to ensure one-way 228 | * reactivity. 229 | * (e.g. calling `updateElement` and `onChange`) 230 | */ 231 | set __data(data: T) { 232 | this._data = data; 233 | this.updateElement(this.getEventHandlerData()); 234 | } 235 | 236 | set __awareness(data: V[]) { 237 | if (!this.updateElementAwareness) { 238 | return; 239 | } 240 | this.awareness = data; 241 | this.updateElementAwareness(this.getAwarenessEventHandlerData()); 242 | } 243 | 244 | getEventHandlerData(): ElementEventHandlerData { 245 | return { 246 | element: this.element, 247 | data: this.data, 248 | localData: this.localData, 249 | awareness: this.awareness, 250 | setData: (newData) => this.setData(newData), 251 | setLocalData: (newData) => this.setLocalData(newData), 252 | setMyAwareness: (newData) => this.setMyAwareness(newData), 253 | }; 254 | } 255 | 256 | getAwarenessEventHandlerData(): ElementAwarenessEventHandlerData { 257 | return { 258 | ...this.getEventHandlerData(), 259 | myAwareness: this.selfAwareness, 260 | }; 261 | } 262 | 263 | getSetupData(): ElementSetupData { 264 | return { 265 | getElement: () => this.element, 266 | getData: () => this.data, 267 | getLocalData: () => this.localData, 268 | getAwareness: () => this.awareness, 269 | setData: (newData) => this.setData(newData), 270 | setLocalData: (newData) => this.setLocalData(newData), 271 | setMyAwareness: (newData) => this.setMyAwareness(newData), 272 | }; 273 | } 274 | 275 | /** 276 | * Public-use setter for data that makes the change to all clients. 277 | */ 278 | setData(data: T): void { 279 | this.onChange(data); 280 | } 281 | 282 | // TODO: this should be keyed on the element to avoid conflicts 283 | setMyAwareness(data: V): void { 284 | if (data === this.selfAwareness) { 285 | // avoid duplicate broadcasts 286 | return; 287 | } 288 | 289 | this.selfAwareness = data; 290 | this.onAwarenessChange(data); 291 | // For some reason unless it's the first time, the localState changing is not called in the `change` observer callback for awareness. So we have to manually update 292 | // the element's awareness rendering here. 293 | this.triggerAwarenessUpdate?.(); 294 | } 295 | 296 | setDataDebounced(data: T) { 297 | this.debouncedOnChange(data); 298 | } 299 | 300 | /** 301 | * Resets the element to its default state. 302 | */ 303 | reset() { 304 | this.setData(this.defaultData); 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /packages/playhtml/src/init.ts: -------------------------------------------------------------------------------- 1 | import { playhtml } from "./main"; 2 | playhtml.init({}); 3 | -------------------------------------------------------------------------------- /packages/playhtml/src/style.scss: -------------------------------------------------------------------------------- 1 | // Library Styles 2 | // TODO: add indicator to all of them that they are interactive, slight wiggle on hover? (how to indicate on mobile?) 3 | // TODO: these timings should scale depending on the size of the element? 4 | // Common classes to every open websites element. 5 | .__playhtml-element { 6 | } 7 | .__playhtml-can-move { 8 | cursor: grab; 9 | transition: transform 150ms; 10 | will-change: transform; 11 | 12 | &.cursordown { 13 | cursor: grabbing; 14 | } 15 | } 16 | .__playhtml-can-spin { 17 | cursor: grab; 18 | transition: transform 250ms; 19 | will-change: transform; 20 | 21 | &.cursordown { 22 | cursor: grabbing; 23 | } 24 | } 25 | .__playhtml-can-grow { 26 | cursor: pointer; 27 | transition: transform 250ms; 28 | will-change: transform; 29 | // TODO: turn cursor into a variable and then allow changing that. 30 | } 31 | .__playhtml-can-toggle { 32 | cursor: pointer; 33 | } 34 | .__playhtml-can-draw { 35 | cursor: pointer; 36 | .__playhtml-draw-container { 37 | position: relative; 38 | width: 100%; 39 | height: 100%; 40 | cursor: none; 41 | 42 | canvas { 43 | position: absolute; 44 | top: 0; 45 | left: 0; 46 | cursor: url("data:image/svg+xml;utf8,🖍️") 47 | 16 0, 48 | auto; /*!emojicursor.app*/ 49 | } 50 | } 51 | } 52 | 53 | // iPhone jiggle animation 54 | // inspo from https://www.kirupa.com/html5/creating_the_ios_icon_jiggle_wobble_effect_in_css.htm 55 | // Show when in "edit" mode? 56 | body .__playhtml-element.editing:nth-child(2n) { 57 | animation-name: jiggle1; 58 | animation-iteration-count: infinite; 59 | transform-origin: 50% 10%; 60 | animation-duration: 0.25s; 61 | animation-delay: var(--jiggle-delay); 62 | } 63 | 64 | body .__playhtml-element.editing:nth-child(2n-1) { 65 | animation-name: jiggle2; 66 | animation-iteration-count: infinite; 67 | animation-direction: alternate; 68 | transform-origin: 30% 5%; 69 | animation-duration: 0.45s; 70 | animation-delay: var(--jiggle-delay); 71 | } 72 | 73 | @keyframes jiggle1 { 74 | 0% { 75 | transform: rotate(-1deg); 76 | animation-timing-function: ease-in; 77 | } 78 | 79 | 50% { 80 | transform: rotate(1.5deg); 81 | animation-timing-function: ease-out; 82 | } 83 | } 84 | 85 | @keyframes jiggle2 { 86 | 0% { 87 | transform: rotate(1deg); 88 | animation-timing-function: ease-in; 89 | } 90 | 91 | 50% { 92 | transform: rotate(-1.5deg); 93 | animation-timing-function: ease-out; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/playhtml/src/utils.ts: -------------------------------------------------------------------------------- 1 | export async function hashElement( 2 | tag: string, 3 | element: Element 4 | ): Promise { 5 | const msgUint8 = new TextEncoder().encode(`${tag}-${element.outerHTML}}`); 6 | const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8); 7 | 8 | const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array 9 | const hashHex = hashArray 10 | .map((b) => b.toString(16).padStart(2, "0")) 11 | .join(""); // convert bytes to hex string 12 | return hashHex; 13 | } 14 | -------------------------------------------------------------------------------- /packages/playhtml/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/playhtml/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/playhtml/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | plugins: [dts({ rollupTypes: true })], 7 | build: { 8 | rollupOptions: { 9 | input: ["src/init.ts", "src/main.ts"], 10 | output: { 11 | inlineDynamicImports: false, 12 | }, 13 | }, 14 | lib: { 15 | entry: path.resolve(__dirname, "src/main.ts"), 16 | formats: ["es"], 17 | name: "playhtml", 18 | fileName: (format, entryName) => { 19 | if (entryName === "init") return `init.${format}.js`; 20 | 21 | return `playhtml.${format}.js`; 22 | }, 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /packages/react/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | The format is based on Keep a Changelog and this project adheres to Semantic Versioning. 4 | 5 | ## 0.4.1 - 2025-04-14 6 | 7 | ### Breaking Changes 8 | 9 | - **REMOVED API** Completely removed the deprecated `withPlay` function. Use `withSharedState` instead (see migration guide in 0.3.1 release notes below). 10 | 11 | ### Enhancements 12 | 13 | - **ENHANCEMENT** Added support for React 19 14 | - Updated peerDependencies to include React 19 15 | - Updated TypeScript definitions to support React 19 16 | - Improvements to provider detection & handling island architectures like Astro 17 | - Added standalone mode functionality to eliminate the requirement for a PlayProvider wrapper 18 | - Enhanced provider detection with clearer error messages when a provider is missing 19 | 20 | ## 0.3.1 - 2024-02-17 21 | 22 | - **NEW API** Replaced the main api `withPlay` with `withSharedState` to make it more functionally clear what it does. This new API also is much cleaner because it removes the need to curry the function. The old `withPlay` API is still available for backwards compatibility but will be removed in the next major version. See some examples below for the comparison: 23 | 24 | **old api** 25 | 26 | ```tsx 27 | export const ToggleSquare = withPlay()( 28 | { defaultData: { on: false } }, 29 | ({ data, setData, ...props }) => { 30 | return ( 31 |
setData({ on: !data.on })} 38 | /> 39 | ); 40 | } 41 | ); 42 | ``` 43 | 44 | **new api** 45 | 46 | ```tsx 47 | export const ToggleSquare = withSharedState( 48 | { defaultData: { on: false } }, 49 | ({ data, setData }, props: Props) => { 50 | return ( 51 |
setData({ on: !data.on })} 58 | /> 59 | ); 60 | } 61 | ); 62 | ``` 63 | 64 | ## 0.2.0 - 2024-01-27 65 | 66 | - **NEW FEATURE** Added eventing support for imperative logic like showing confetti whenever someone clicks a button which don't depend on a reacting to a data value changing. See the README under "eventing" for more details on how to set this up.` 67 | 68 | ## 0.1.0 69 | 70 | - works with more complex state 71 | - allows for setting data inside your render function 72 | - passes ref into render function for accessing the component information / imperatively acting on it 73 | - handles passing in fragments and multiple children into render function 74 | - works with Next.JS and examples 75 | 76 | ## 0.0.12 - 2023-09-11 77 | 78 | - works when changing props 79 | 80 | ## 0.0.11 - 2023-09-11 81 | 82 | - ok confirmed working now and handles unmount 83 | 84 | ## 0.0.2 - 2023-09-07 85 | 86 | - basic react support! 87 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | # @playhtml/react 2 | 3 | A react provider for [`playhtml`](https://github.com/spencerc99/playhtml). 4 | 5 | `@playhtml/react` gives you a hooks-like interface for creating realt-ime interactive and persistent elements. It manages all the state syncing for you, so you can reactively render your component based on whatever data is coming in. 6 | 7 | Install by using your preferred package manager 8 | 9 | ```bash 10 | npm install @playhtml/react # or 11 | # yarn add @playhtml/react 12 | ``` 13 | 14 | ## Compatibility 15 | 16 | `@playhtml/react` is compatible with React versions 16.8.0 and above, including React 17, React 18, and React 19. 17 | 18 | ## Usage 19 | 20 | First, wrap your app in the `PlayProvider` component. This will handling initializing the `playhtml` client and setting up the connection to the server. You can specify the same `initOptions` to `PlayProvider` as you would when initializing the client directly. 21 | 22 | ```tsx 23 | import { PlayProvider } from "@playhtml/react"; 24 | 25 | export default function App() { 26 | return ( 27 | 35 | {/* rest of your app... */} 36 | 37 | ); 38 | } 39 | ``` 40 | 41 | Then, use `withSharedState` to wrap your components in a higher order component to enhance them with live, shared data. `withSharedState` takes in a `defaultData` along with other configuration and a norma functional component definition, to which it will pass through the data and a callback to change that data `setData`. 42 | 43 | For example, to create a rectangle that switches between on and off states (designated by the background), you can use the following code: 44 | 45 | ```tsx 46 | import { withSharedState } from "@playhtml/react"; 47 | interface Props {} 48 | 49 | export const ToggleSquare = withSharedState( 50 | { defaultData: { on: false } }, 51 | ({ data, setData }, props: Props) => { 52 | return ( 53 |
setData({ on: !data.on })} 58 | /> 59 | ); 60 | } 61 | ); 62 | ``` 63 | 64 | https://github.com/spencerc99/playhtml/assets/14796580/beff368e-b659-4db0-b314-16d10b09c31f 65 | 66 | A more complex example uses `awareness` to show the number of people on the page and their associated color: 67 | 68 | ```tsx 69 | export const OnlineIndicator = withSharedState( 70 | { defaultData: {}, myDefaultAwareness: "#008000", id: "online-indicator" }, 71 | ({ myAwareness, setMyAwareness, awareness }) => { 72 | const myAwarenessIdx = myAwareness ? awareness.indexOf(myAwareness) : -1; 73 | return ( 74 | <> 75 | {awareness.map((val, idx) => ( 76 |
89 | ))} 90 | setMyAwareness(e.target.value)} 93 | value={myAwareness} 94 | /> 95 | 96 | ); 97 | } 98 | ); 99 | ``` 100 | 101 | ![image](https://github.com/spencerc99/playhtml/assets/14796580/37b75f82-7a09-4a35-8794-3003425726f5) 102 | 103 | If you need access to the element's custom props for creating the configuration, you can just pass a callback that returns the configuration object: 104 | 105 | ```tsx 106 | interface Reaction { 107 | emoji: string; 108 | count: number; 109 | } 110 | 111 | export const ReactionView = withSharedState( 112 | ({ reaction: { count } }) => ({ 113 | defaultData: { count }, 114 | }), 115 | ({ data, setData, ref }, props: { reaction: Reaction }) => { 116 | const { 117 | reaction: { emoji }, 118 | } = props; 119 | const [hasReacted, setHasReacted] = useState(false); 120 | 121 | useEffect(() => { 122 | if (ref.current) { 123 | setHasReacted(Boolean(localStorage.getItem(ref.current.id))); 124 | } 125 | }, [ref.current?.id]); 126 | 127 | return ( 128 | 150 | ); 151 | } 152 | ); 153 | ``` 154 | 155 | ### Examples 156 | 157 | You can find plenty of examples under `packages/react/examples` to see how to use `@playhtml/react` in a variety of ways. Live examples can also be found at https://playhtml.fun/experiments/one/ and https://playhtml.fun/experiments/two/ (all located inside the repo). 158 | 159 | ### Eventing 160 | 161 | You can set up imperative logic that doesn't depend on a data value changing (like triggering confetti when someone clicks in an area) by registering events with playhtml. You can either pass in a list of events to `PlayProvider` or you can call `playhtml.registerPlayEventListener` to register an event at any time. 162 | 163 | An example on a hook that returns a callback to trigger shared confetti (from `packages/react/examples/Confetti.tsx`): 164 | 165 | ```tsx 166 | import React from "react"; 167 | import { PlayContext } from "@playhtml/react"; 168 | import { useContext, useEffect } from "react"; 169 | 170 | const ConfettiEventType = "confetti"; 171 | 172 | export function useConfetti() { 173 | const { 174 | registerPlayEventListener, 175 | removePlayEventListener, 176 | dispatchPlayEvent, 177 | } = useContext(PlayContext); 178 | 179 | useEffect(() => { 180 | const id = registerPlayEventListener(ConfettiEventType, { 181 | onEvent: () => { 182 | // requires importing 183 | // somewhere in your app 184 | window.confetti({ 185 | particleCount: 100, 186 | spread: 70, 187 | origin: { y: 0.6 }, 188 | }); 189 | }, 190 | }); 191 | 192 | return () => removePlayEventListener(ConfettiEventType, id); 193 | }, []); 194 | 195 | return () => { 196 | dispatchPlayEvent({ type: ConfettiEventType }); 197 | }; 198 | } 199 | 200 | // Usage 201 | export function ConfettiZone() { 202 | const triggerConfetti = useConfetti(); 203 | 204 | return ( 205 |
triggerConfetti()} 209 | > 210 |

CONFETTI ZONE

211 |
212 | ); 213 | } 214 | ``` 215 | 216 | https://github.com/spencerc99/playhtml/assets/14796580/bd8ecfaf-73ab-4aa2-9312-8917809f52a2 217 | 218 | For full configuration, see the interface below. 219 | 220 | ```tsx 221 | interface CanPlayElementProps { 222 | id?: string; // the id of this element, required if the top-level child is a React Fragment. Defaults to the id of the top-level child or a hash of the contents of the children if not specified. 223 | defaultData: T; // the default data for this element 224 | myDefaultAwareness?: V; // the default awareness for this element 225 | children: (props: ReactElementEventHandlerData) => React.ReactNode; 226 | } 227 | 228 | // callback props 229 | interface ReactElementEventHandlerData { 230 | data: T; // the data for this element 231 | setData: (data: T) => void; // sets the data for this element 232 | awareness: V[]; // the awareness values of all clients (including self) 233 | myAwareness?: V; // the specific awareness of this client 234 | setMyAwareness: (data: V) => void; // sets "myAwareness" to the given value and syncs it to other clients 235 | } 236 | ``` 237 | 238 | Refer to `packages/react/example.tsx` for a full list of examples. 239 | 240 | ## Open considerations 241 | 242 | - how to best handle configuring how persistence works? (e.g. none vs. locally vs. globally)? 243 | - Currently the separate configurations are managed by housing the data in completely separate stores and function abstractions. `setAwareness` is used for no persistence, there is no configuration for only local persistence, and `setData` persists the data globally. 244 | - Maybe this would be better if it was a per-data-key configuration option? Likely a `persistenceOptions` object with an enum value for `none`, `local`, and `global` for each key. It wouldn't allow for nested configuration. 245 | - `awareness` should probably be separated into `myAwareness` and `othersAwareness`. 246 | - is it more ergonomic to make a hooks-esque interface and use some sort of callback from `PlayProvider` to get/set data? Hard to do this without requiring the user to specify some "id" for the data though. 247 | -------------------------------------------------------------------------------- /packages/react/example.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CanPlayElement } from "./src/index"; 3 | 4 | export function SharedYoutube(video: string) { 5 | // TODO: extract url 6 | // 2. This code loads the IFrame Player API code asynchronously. 7 | var tag = document.createElement("script"); 8 | 9 | tag.src = "https://www.youtube.com/iframe_api"; 10 | var firstScriptTag = document.getElementsByTagName("script")[0]; 11 | firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); 12 | 13 | // 3. This function creates an