diff --git a/.gitignore b/.gitignore index e0f570c..4c4e804 100644 --- a/.gitignore +++ b/.gitignore @@ -206,5 +206,4 @@ marimo/_static/ marimo/_lsp/ __marimo__/ -assets/ _vite diff --git a/README-zh_CN.md b/README-zh_CN.md index 03c8ef4..c9ac763 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -103,8 +103,12 @@ pip install dash-vite-plugin 有关详细的使用示例,请参阅示例文件: -- [Vue.js 示例](example_vue.py) - 演示如何将插件与 Vue.js 一起使用 -- [React 示例](example_react.py) - 演示如何将插件与 React 一起使用 +- [React Basic Demo](./examples/react-basic-demo) - 集成`React`的基础示例 +- [React Bits Grid Scan Demo](./examples/react-bits-grid-scan-demo) - 示例:集成使用`react-bits`中的`GridScan`组件 +- [React Bits Lightning Demo](./examples/react-bits-lightning-demo) - 示例:集成使用`react-bits`中的`Lightning`组件 +- [React Bits Shiny Text Demo](./examples/react-bits-shiny-text-demo) - 示例:集成使用`react-bits`中的`ShinyText`组件 +- [Vue Basic Demo](./examples/vue-basic-demo) - 集成`Vue`的基础示例 + 这些示例展示了如何使用不同前端框架设置插件,并包含测试回调以验证集成是否正常工作。 diff --git a/README.md b/README.md index 5306178..8c0b209 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,11 @@ Note: This plugin requires Python 3.8 or higher. For detailed usage examples, please refer to the example files: -- [Vue.js Example](example_vue.py) - Demonstrates how to use the plugin with Vue.js -- [React Example](example_react.py) - Demonstrates how to use the plugin with React +- [React Basic Demo](./examples/react-basic-demo) - Basic usage with React +- [React Bits Grid Scan Demo](./examples/react-bits-grid-scan-demo) - Example: integrating `react-bits` GridScan component +- [React Bits Lightning Demo](./examples/react-bits-lightning-demo) - Example: integrating `react-bits` Lightning component +- [React Bits Shiny Text Demo](./examples/react-bits-shiny-text-demo) - Example: integrating `react-bits` ShinyText component +- [Vue Basic Demo](./examples/vue-basic-demo) - Basic usage with Vue These examples show how to set up the plugin with different frontend frameworks and include test callbacks to verify that the integration is working correctly. diff --git a/example_react.py b/example_react.py deleted file mode 100644 index e88ff28..0000000 --- a/example_react.py +++ /dev/null @@ -1,157 +0,0 @@ -import os -from dash import Dash, html, Input, Output -from dash_vite_plugin import VitePlugin, NpmPackage - - -# Create assets structure -os.makedirs('assets/js', exist_ok=True) -os.makedirs('assets/react', exist_ok=True) - -# Create a simple React component -with open('assets/react/App.jsx', 'w') as f: - f.write("""import React, { useState } from 'react'; - -const App = () => { - const [message, setMessage] = useState("Hello from React!"); - - const updateDash = () => { - setMessage("Hello from React!"); - window.dash_clientside.set_props('dash-title', { children: 'Hello from React!' }); - }; - - const updateMessage = () => { - setMessage("Hello from Dash!"); - }; - - return ( -
-

{message}

-
- -
- -
- ); -}; - -export default App; - """) - -# Create JavaScript file that imports and renders React -with open('assets/js/main.js', 'w') as f: - f.write("""import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "../react/App.jsx"; - -// Add a global variable to test JavaScript execution -window.testVariable = "VitePluginReactTest"; - -// Wait for the mount point to appear and then create the application -function waitForElement(selector) { - return new Promise((resolve) => { - const element = document.querySelector(selector); - if (element) { - resolve(element); - return; - } - - const observer = new MutationObserver((mutations) => { - const targetElement = document.querySelector(selector); - if (targetElement) { - observer.disconnect(); - resolve(targetElement); - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - }); - }); -} - -// Wait for the mount point to appear and then create the application -waitForElement("#react-container") - .then((element) => { - const root = createRoot(element); - root.render(React.createElement(App)); - }) - .catch((error) => { - console.error("Unable to find mount point:", error); - }); - """) - -# Create VitePlugin instance with React support -vite_plugin = VitePlugin( - build_assets_paths=['assets/js', 'assets/react'], - entry_js_paths=['assets/js/main.js'], - npm_packages=[ - NpmPackage('react'), - NpmPackage('react-dom'), - ], - download_node=True, - clean_after=True, -) - -# Call setup BEFORE creating Dash app (as required by the plugin architecture) -vite_plugin.setup() - -# Create a Dash app -app = Dash(__name__) - -# Call use AFTER creating Dash app (as required by the plugin architecture) -vite_plugin.use(app) - -# Define app layout with a container for React -app.layout = html.Div( - [ - html.H1('Vite Plugin Test - React Support', id='header'), - html.P('This tests the Vite plugin with React support.', id='paragraph'), - # Container for React app - html.Div( - [ - 'The content from React', - html.Div(id='react-container'), - ] - ), - html.Div( - [ - 'The content from Dash', - html.Div( - [html.H1('Hello from Dash!', id='dash-title'), html.Button('Control React', id='dash-button')], - id='dash-app', - style={'margin': '20px'}, - ), - ], - id='dash-container', - ), - ] -) - - -# Add callback to test React functionality with a simpler approach -app.clientside_callback( - """ - function(n_clicks) { - if (n_clicks > 0) { - const reactApp = document.getElementById('react-app'); - if (reactApp) { - const button = reactApp.querySelector('#control-react-button'); - if (button) { - button.click(); - return 'Hello from Dash!'; - } - } - } - return 'Hello from Dash!'; - } - """, - Output('dash-title', 'children'), - Input('dash-button', 'n_clicks'), -) - - -if __name__ == '__main__': - app.run(debug=True) diff --git a/example_vue.py b/example_vue.py deleted file mode 100644 index ed487df..0000000 --- a/example_vue.py +++ /dev/null @@ -1,162 +0,0 @@ -import os -from dash import Dash, html, Input, Output -from dash_vite_plugin import VitePlugin, NpmPackage - - -# Create assets structure -os.makedirs('assets/js', exist_ok=True) -os.makedirs('assets/vue', exist_ok=True) - -# Create a simple Vue component using Vue 3 Composition API -with open('assets/vue/App.vue', 'w') as f: - f.write(""" - - - - - """) - -# Create JavaScript file that imports and mounts Vue -with open('assets/js/main.js', 'w') as f: - f.write("""import { createApp } from "vue"; -import App from "../vue/App.vue"; - -// Add a global variable to test JavaScript execution -window.testVariable = "VitePluginVueTest"; - -// Mount the Vue app -function waitForElement(selector) { - return new Promise((resolve) => { - const element = document.querySelector(selector); - if (element) { - resolve(element); - return; - } - - const observer = new MutationObserver((mutations) => { - const targetElement = document.querySelector(selector); - if (targetElement) { - observer.disconnect(); - resolve(targetElement); - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - }); - }); -} - -// Wait for the mount point to appear and then create the application -waitForElement("#vue-container") - .then((element) => { - const app = createApp(App); - app.mount(element); - }) - .catch((error) => { - console.error("Unable to find mount point:", error); - }); - """) - -# Create VitePlugin instance with Vue support -vite_plugin = VitePlugin( - build_assets_paths=['assets/js', 'assets/vue'], - entry_js_paths=['assets/js/main.js'], - npm_packages=[ - NpmPackage('vue'), - ], - download_node=True, - clean_after=True, -) - -# Call setup BEFORE creating Dash app (as required by the plugin architecture) -vite_plugin.setup() - -# Create a Dash app -app = Dash(__name__) - -# Call use AFTER creating Dash app (as required by the plugin architecture) -vite_plugin.use(app) - -# Define app layout with a container for Vue -app.layout = html.Div( - [ - html.H1('Vite Plugin Test - Vue Support', id='header'), - html.P('This tests the Vite plugin with Vue support.', id='paragraph'), - # Container for Vue app - html.Div( - [ - 'The content from Vue', - html.Div(id='vue-container'), - ] - ), - html.Div( - [ - 'The content from Dash', - html.Div( - [html.H1('Hello from Dash!', id='dash-title'), html.Button('Control Vue', id='dash-button')], - id='dash-app', - style={'margin': '20px'}, - ), - ], - id='dash-container', - ), - ] -) - - -# Add callback to test Vue functionality with a simpler approach -app.clientside_callback( - """ - function(n_clicks) { - if (n_clicks > 0) { - const vueApp = document.getElementById('vue-app'); - if (vueApp) { - const button = vueApp.querySelector('#control-vue-button'); - if (button) { - button.click(); - return 'Hello from Dash!'; - } - } - } - return 'Hello from Dash!'; - } - """, - Output('dash-title', 'children'), - Input('dash-button', 'n_clicks'), -) - - -if __name__ == '__main__': - app.run(debug=True) diff --git a/examples/react-basic-demo/app.py b/examples/react-basic-demo/app.py new file mode 100644 index 0000000..c1e9394 --- /dev/null +++ b/examples/react-basic-demo/app.py @@ -0,0 +1,77 @@ +import dash +from dash import html +from dash.dependencies import Input, Output +from dash_vite_plugin import VitePlugin, NpmPackage + +# Create VitePlugin instance with React support +vite_plugin = VitePlugin( + build_assets_paths=['assets/js', 'assets/react'], + entry_js_paths=['assets/js/main.js'], + npm_packages=[ + NpmPackage('react'), + NpmPackage('react-dom'), + ], + download_node=True, + clean_after=True, +) + +# Call setup BEFORE creating Dash app (as required by the plugin architecture) +vite_plugin.setup() + +# Create a Dash app +app = dash.Dash(__name__) + +# Call use AFTER creating Dash app (as required by the plugin architecture) +vite_plugin.use(app) + +# Define app layout with a container for React +app.layout = html.Div( + [ + html.H1('Vite Plugin Test - React Support', id='header'), + html.P('This tests the Vite plugin with React support.', id='paragraph'), + # Container for React app + html.Div( + [ + 'The content from React', + html.Div(id='react-container'), + ] + ), + html.Div( + [ + 'The content from Dash', + html.Div( + [html.H1('Hello from Dash!', id='dash-title'), html.Button('Control React', id='dash-button')], + id='dash-app', + style={'margin': '20px'}, + ), + ], + id='dash-container', + ), + ] +) + + +# Add callback to test React functionality with a simpler approach +app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks > 0) { + const reactApp = document.getElementById('react-app'); + if (reactApp) { + const button = reactApp.querySelector('#control-react-button'); + if (button) { + button.click(); + return 'Hello from Dash!'; + } + } + } + return 'Hello from Dash!'; + } + """, + Output('dash-title', 'children'), + Input('dash-button', 'n_clicks'), +) + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/examples/react-basic-demo/assets/js/main.js b/examples/react-basic-demo/assets/js/main.js new file mode 100644 index 0000000..1015043 --- /dev/null +++ b/examples/react-basic-demo/assets/js/main.js @@ -0,0 +1,40 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "../react/App.jsx"; + +// Add a global variable to test JavaScript execution +window.testVariable = "VitePluginReactTest"; + +// Wait for the mount point to appear and then create the application +function waitForElement(selector) { + return new Promise((resolve) => { + const element = document.querySelector(selector); + if (element) { + resolve(element); + return; + } + + const observer = new MutationObserver((mutations) => { + const targetElement = document.querySelector(selector); + if (targetElement) { + observer.disconnect(); + resolve(targetElement); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); +} + +// Wait for the mount point to appear and then create the application +waitForElement("#react-container") + .then((element) => { + const root = createRoot(element); + root.render(React.createElement(App)); + }) + .catch((error) => { + console.error("Unable to find mount point:", error); + }); diff --git a/examples/react-basic-demo/assets/react/App.jsx b/examples/react-basic-demo/assets/react/App.jsx new file mode 100644 index 0000000..b7acdb7 --- /dev/null +++ b/examples/react-basic-demo/assets/react/App.jsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; + +const App = () => { + const [message, setMessage] = useState("Hello from React!"); + + const updateDash = () => { + setMessage("Hello from React!"); + window.dash_clientside.set_props('dash-title', { children: 'Hello from React!' }); + }; + + const updateMessage = () => { + setMessage("Hello from Dash!"); + }; + + return ( +
+

{message}

+
+ +
+ +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/examples/react-basic-demo/requirements.txt b/examples/react-basic-demo/requirements.txt new file mode 100644 index 0000000..5f7b3fb --- /dev/null +++ b/examples/react-basic-demo/requirements.txt @@ -0,0 +1 @@ +dash-vite-plugin>=0.1.3 \ No newline at end of file diff --git a/examples/react-bits-grid-scan-demo/app.py b/examples/react-bits-grid-scan-demo/app.py new file mode 100644 index 0000000..5c9347b --- /dev/null +++ b/examples/react-bits-grid-scan-demo/app.py @@ -0,0 +1,41 @@ +import dash +from dash import html +import feffery_utils_components as fuc +from dash_vite_plugin import VitePlugin, NpmPackage +from dash.dependencies import Input, State, ClientsideFunction + +vite_plugin = VitePlugin( + build_assets_paths=['assets'], + entry_js_paths=['assets/callback.js'], + npm_packages=[ + NpmPackage('react'), + NpmPackage('react-dom'), + NpmPackage('three'), + NpmPackage('postprocessing'), + NpmPackage('face-api.js'), + ], + download_node=True, + clean_after=False, # 开发阶段建议设置为False以加速构建 +) + +vite_plugin.setup() + +app = dash.Dash(__name__) + +vite_plugin.use(app) + +app.layout = html.Div( + [ + fuc.FefferyTimeout(id='trigger-background-init', delay=100), + html.Div(id='background-mount'), + ] +) + +app.clientside_callback( + ClientsideFunction(namespace='clientside', function_name='renderBackground'), + Input('trigger-background-init', 'timeoutCount'), + State('background-mount', 'id'), +) + +if __name__ == '__main__': + app.run(debug=True) diff --git a/examples/react-bits-grid-scan-demo/assets/callback.js b/examples/react-bits-grid-scan-demo/assets/callback.js new file mode 100644 index 0000000..4e657d0 --- /dev/null +++ b/examples/react-bits-grid-scan-demo/assets/callback.js @@ -0,0 +1,15 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import GridScanComponent from "./react_components/GridScan.jsx"; + +window.dash_clientside = Object.assign({}, window.dash_clientside, { + clientside: { + renderBackground: (timeoutCount, id) => { + const container = document.getElementById(id); + const root = createRoot(container); + root.render(React.createElement(GridScanComponent)); + + return window.dash_clientside.no_update; + }, + }, +}); diff --git a/examples/react-bits-grid-scan-demo/assets/react_components/GridScan.css b/examples/react-bits-grid-scan-demo/assets/react_components/GridScan.css new file mode 100644 index 0000000..5974000 --- /dev/null +++ b/examples/react-bits-grid-scan-demo/assets/react_components/GridScan.css @@ -0,0 +1,44 @@ +.gridscan { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +.gridscan__preview { + position: absolute; + right: 12px; + bottom: 12px; + width: 220px; + height: 132px; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.25); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + background: #000; + color: #fff; + font: + 12px/1.2 system-ui, + -apple-system, + Segoe UI, + Roboto, + sans-serif; + pointer-events: none; +} + +.gridscan__video { + width: 100%; + height: 100%; + object-fit: cover; + transform: scaleX(-1); +} + +.gridscan__badge { + position: absolute; + left: 8px; + top: 8px; + padding: 2px 6px; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + backdrop-filter: blur(4px); +} \ No newline at end of file diff --git a/examples/react-bits-grid-scan-demo/assets/react_components/GridScan.jsx b/examples/react-bits-grid-scan-demo/assets/react_components/GridScan.jsx new file mode 100644 index 0000000..456bce7 --- /dev/null +++ b/examples/react-bits-grid-scan-demo/assets/react_components/GridScan.jsx @@ -0,0 +1,915 @@ +import { useEffect, useRef, useState } from 'react'; +import { EffectComposer, RenderPass, EffectPass, BloomEffect, ChromaticAberrationEffect } from 'postprocessing'; +import * as THREE from 'three'; +import * as faceapi from 'face-api.js'; +import './GridScan.css'; + +const vert = ` +varying vec2 vUv; +void main(){ + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); +} +`; + +const frag = ` +precision highp float; +uniform vec3 iResolution; +uniform float iTime; +uniform vec2 uSkew; +uniform float uTilt; +uniform float uYaw; +uniform float uLineThickness; +uniform vec3 uLinesColor; +uniform vec3 uScanColor; +uniform float uGridScale; +uniform float uLineStyle; +uniform float uLineJitter; +uniform float uScanOpacity; +uniform float uScanDirection; +uniform float uNoise; +uniform float uBloomOpacity; +uniform float uScanGlow; +uniform float uScanSoftness; +uniform float uPhaseTaper; +uniform float uScanDuration; +uniform float uScanDelay; +varying vec2 vUv; + +uniform float uScanStarts[8]; +uniform float uScanCount; + +const int MAX_SCANS = 8; + +float smoother01(float a, float b, float x){ + float t = clamp((x - a) / max(1e-5, (b - a)), 0.0, 1.0); + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) +{ + vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; + + vec3 ro = vec3(0.0); + vec3 rd = normalize(vec3(p, 2.0)); + + float cR = cos(uTilt), sR = sin(uTilt); + rd.xy = mat2(cR, -sR, sR, cR) * rd.xy; + + float cY = cos(uYaw), sY = sin(uYaw); + rd.xz = mat2(cY, -sY, sY, cY) * rd.xz; + + vec2 skew = clamp(uSkew, vec2(-0.7), vec2(0.7)); + rd.xy += skew * rd.z; + + vec3 color = vec3(0.0); + float minT = 1e20; + float gridScale = max(1e-5, uGridScale); + float fadeStrength = 2.0; + vec2 gridUV = vec2(0.0); + + float hitIsY = 1.0; + for (int i = 0; i < 4; i++) + { + float isY = float(i < 2); + float pos = mix(-0.2, 0.2, float(i)) * isY + mix(-0.5, 0.5, float(i - 2)) * (1.0 - isY); + float num = pos - (isY * ro.y + (1.0 - isY) * ro.x); + float den = isY * rd.y + (1.0 - isY) * rd.x; + float t = num / den; + vec3 h = ro + rd * t; + + float depthBoost = smoothstep(0.0, 3.0, h.z); + h.xy += skew * 0.15 * depthBoost; + + bool use = t > 0.0 && t < minT; + gridUV = use ? mix(h.zy, h.xz, isY) / gridScale : gridUV; + minT = use ? t : minT; + hitIsY = use ? isY : hitIsY; + } + + vec3 hit = ro + rd * minT; + float dist = length(hit - ro); + + float jitterAmt = clamp(uLineJitter, 0.0, 1.0); + if (jitterAmt > 0.0) { + vec2 j = vec2( + sin(gridUV.y * 2.7 + iTime * 1.8), + cos(gridUV.x * 2.3 - iTime * 1.6) + ) * (0.15 * jitterAmt); + gridUV += j; + } + float fx = fract(gridUV.x); + float fy = fract(gridUV.y); + float ax = min(fx, 1.0 - fx); + float ay = min(fy, 1.0 - fy); + float wx = fwidth(gridUV.x); + float wy = fwidth(gridUV.y); + float halfPx = max(0.0, uLineThickness) * 0.5; + + float tx = halfPx * wx; + float ty = halfPx * wy; + + float aax = wx; + float aay = wy; + + float lineX = 1.0 - smoothstep(tx, tx + aax, ax); + float lineY = 1.0 - smoothstep(ty, ty + aay, ay); + if (uLineStyle > 0.5) { + float dashRepeat = 4.0; + float dashDuty = 0.5; + float vy = fract(gridUV.y * dashRepeat); + float vx = fract(gridUV.x * dashRepeat); + float dashMaskY = step(vy, dashDuty); + float dashMaskX = step(vx, dashDuty); + if (uLineStyle < 1.5) { + lineX *= dashMaskY; + lineY *= dashMaskX; + } else { + float dotRepeat = 6.0; + float dotWidth = 0.18; + float cy = abs(fract(gridUV.y * dotRepeat) - 0.5); + float cx = abs(fract(gridUV.x * dotRepeat) - 0.5); + float dotMaskY = 1.0 - smoothstep(dotWidth, dotWidth + fwidth(gridUV.y * dotRepeat), cy); + float dotMaskX = 1.0 - smoothstep(dotWidth, dotWidth + fwidth(gridUV.x * dotRepeat), cx); + lineX *= dotMaskY; + lineY *= dotMaskX; + } + } + float primaryMask = max(lineX, lineY); + + vec2 gridUV2 = (hitIsY > 0.5 ? hit.xz : hit.zy) / gridScale; + if (jitterAmt > 0.0) { + vec2 j2 = vec2( + cos(gridUV2.y * 2.1 - iTime * 1.4), + sin(gridUV2.x * 2.5 + iTime * 1.7) + ) * (0.15 * jitterAmt); + gridUV2 += j2; + } + float fx2 = fract(gridUV2.x); + float fy2 = fract(gridUV2.y); + float ax2 = min(fx2, 1.0 - fx2); + float ay2 = min(fy2, 1.0 - fy2); + float wx2 = fwidth(gridUV2.x); + float wy2 = fwidth(gridUV2.y); + float tx2 = halfPx * wx2; + float ty2 = halfPx * wy2; + float aax2 = wx2; + float aay2 = wy2; + float lineX2 = 1.0 - smoothstep(tx2, tx2 + aax2, ax2); + float lineY2 = 1.0 - smoothstep(ty2, ty2 + aay2, ay2); + if (uLineStyle > 0.5) { + float dashRepeat2 = 4.0; + float dashDuty2 = 0.5; + float vy2m = fract(gridUV2.y * dashRepeat2); + float vx2m = fract(gridUV2.x * dashRepeat2); + float dashMaskY2 = step(vy2m, dashDuty2); + float dashMaskX2 = step(vx2m, dashDuty2); + if (uLineStyle < 1.5) { + lineX2 *= dashMaskY2; + lineY2 *= dashMaskX2; + } else { + float dotRepeat2 = 6.0; + float dotWidth2 = 0.18; + float cy2 = abs(fract(gridUV2.y * dotRepeat2) - 0.5); + float cx2 = abs(fract(gridUV2.x * dotRepeat2) - 0.5); + float dotMaskY2 = 1.0 - smoothstep(dotWidth2, dotWidth2 + fwidth(gridUV2.y * dotRepeat2), cy2); + float dotMaskX2 = 1.0 - smoothstep(dotWidth2, dotWidth2 + fwidth(gridUV2.x * dotRepeat2), cx2); + lineX2 *= dotMaskY2; + lineY2 *= dotMaskX2; + } + } + float altMask = max(lineX2, lineY2); + + float edgeDistX = min(abs(hit.x - (-0.5)), abs(hit.x - 0.5)); + float edgeDistY = min(abs(hit.y - (-0.2)), abs(hit.y - 0.2)); + float edgeDist = mix(edgeDistY, edgeDistX, hitIsY); + float edgeGate = 1.0 - smoothstep(gridScale * 0.5, gridScale * 2.0, edgeDist); + altMask *= edgeGate; + + float lineMask = max(primaryMask, altMask); + + float fade = exp(-dist * fadeStrength); + + float dur = max(0.05, uScanDuration); + float del = max(0.0, uScanDelay); + float scanZMax = 2.0; + float widthScale = max(0.1, uScanGlow); + float sigma = max(0.001, 0.18 * widthScale * uScanSoftness); + float sigmaA = sigma * 2.0; + + float combinedPulse = 0.0; + float combinedAura = 0.0; + + float cycle = dur + del; + float tCycle = mod(iTime, cycle); + float scanPhase = clamp((tCycle - del) / dur, 0.0, 1.0); + float phase = scanPhase; + if (uScanDirection > 0.5 && uScanDirection < 1.5) { + phase = 1.0 - phase; + } else if (uScanDirection > 1.5) { + float t2 = mod(max(0.0, iTime - del), 2.0 * dur); + phase = (t2 < dur) ? (t2 / dur) : (1.0 - (t2 - dur) / dur); + } + float scanZ = phase * scanZMax; + float dz = abs(hit.z - scanZ); + float lineBand = exp(-0.5 * (dz * dz) / (sigma * sigma)); + float taper = clamp(uPhaseTaper, 0.0, 0.49); + float headW = taper; + float tailW = taper; + float headFade = smoother01(0.0, headW, phase); + float tailFade = 1.0 - smoother01(1.0 - tailW, 1.0, phase); + float phaseWindow = headFade * tailFade; + float pulseBase = lineBand * phaseWindow; + combinedPulse += pulseBase * clamp(uScanOpacity, 0.0, 1.0); + float auraBand = exp(-0.5 * (dz * dz) / (sigmaA * sigmaA)); + combinedAura += (auraBand * 0.25) * phaseWindow * clamp(uScanOpacity, 0.0, 1.0); + + for (int i = 0; i < MAX_SCANS; i++) { + if (float(i) >= uScanCount) break; + float tActiveI = iTime - uScanStarts[i]; + float phaseI = clamp(tActiveI / dur, 0.0, 1.0); + if (uScanDirection > 0.5 && uScanDirection < 1.5) { + phaseI = 1.0 - phaseI; + } else if (uScanDirection > 1.5) { + phaseI = (phaseI < 0.5) ? (phaseI * 2.0) : (1.0 - (phaseI - 0.5) * 2.0); + } + float scanZI = phaseI * scanZMax; + float dzI = abs(hit.z - scanZI); + float lineBandI = exp(-0.5 * (dzI * dzI) / (sigma * sigma)); + float headFadeI = smoother01(0.0, headW, phaseI); + float tailFadeI = 1.0 - smoother01(1.0 - tailW, 1.0, phaseI); + float phaseWindowI = headFadeI * tailFadeI; + combinedPulse += lineBandI * phaseWindowI * clamp(uScanOpacity, 0.0, 1.0); + float auraBandI = exp(-0.5 * (dzI * dzI) / (sigmaA * sigmaA)); + combinedAura += (auraBandI * 0.25) * phaseWindowI * clamp(uScanOpacity, 0.0, 1.0); + } + + float lineVis = lineMask; + vec3 gridCol = uLinesColor * lineVis * fade; + vec3 scanCol = uScanColor * combinedPulse; + vec3 scanAura = uScanColor * combinedAura; + + color = gridCol + scanCol + scanAura; + + float n = fract(sin(dot(gl_FragCoord.xy + vec2(iTime * 123.4), vec2(12.9898,78.233))) * 43758.5453123); + color += (n - 0.5) * uNoise; + color = clamp(color, 0.0, 1.0); + float alpha = clamp(max(lineVis, combinedPulse), 0.0, 1.0); + float gx = 1.0 - smoothstep(tx * 2.0, tx * 2.0 + aax * 2.0, ax); + float gy = 1.0 - smoothstep(ty * 2.0, ty * 2.0 + aay * 2.0, ay); + float halo = max(gx, gy) * fade; + alpha = max(alpha, halo * clamp(uBloomOpacity, 0.0, 1.0)); + fragColor = vec4(color, alpha); +} + +void main(){ + vec4 c; + mainImage(c, vUv * iResolution.xy); + gl_FragColor = c; +} +`; + +const GridScan = ({ + enableWebcam = false, + showPreview = false, + modelsPath = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights', + sensitivity = 0.55, + lineThickness = 1, + linesColor = '#392e4e', + scanColor = '#FF9FFC', + scanOpacity = 0.4, + gridScale = 0.1, + lineStyle = 'solid', + lineJitter = 0.1, + scanDirection = 'pingpong', + enablePost = true, + bloomIntensity = 0, + bloomThreshold = 0, + bloomSmoothing = 0, + chromaticAberration = 0.002, + noiseIntensity = 0.01, + scanGlow = 0.5, + scanSoftness = 2, + scanPhaseTaper = 0.9, + scanDuration = 2.0, + scanDelay = 2.0, + enableGyro = false, + scanOnClick = false, + snapBackDelay = 250, + className, + style +}) => { + const containerRef = useRef(null); + const videoRef = useRef(null); + + const rendererRef = useRef(null); + const materialRef = useRef(null); + const composerRef = useRef(null); + const bloomRef = useRef(null); + const chromaRef = useRef(null); + const rafRef = useRef(null); + + const [modelsReady, setModelsReady] = useState(false); + const [uiFaceActive, setUiFaceActive] = useState(false); + + const lookTarget = useRef(new THREE.Vector2(0, 0)); + const tiltTarget = useRef(0); + const yawTarget = useRef(0); + + const lookCurrent = useRef(new THREE.Vector2(0, 0)); + const lookVel = useRef(new THREE.Vector2(0, 0)); + const tiltCurrent = useRef(0); + const tiltVel = useRef(0); + const yawCurrent = useRef(0); + const yawVel = useRef(0); + + const MAX_SCANS = 8; + const scanStartsRef = useRef([]); + + const pushScan = t => { + const arr = scanStartsRef.current.slice(); + if (arr.length >= MAX_SCANS) arr.shift(); + arr.push(t); + scanStartsRef.current = arr; + if (materialRef.current) { + const u = materialRef.current.uniforms; + const buf = new Array(MAX_SCANS).fill(0); + for (let i = 0; i < arr.length && i < MAX_SCANS; i++) buf[i] = arr[i]; + u.uScanStarts.value = buf; + u.uScanCount.value = arr.length; + } + }; + + const bufX = useRef([]); + const bufY = useRef([]); + const bufT = useRef([]); + const bufYaw = useRef([]); + + const s = THREE.MathUtils.clamp(sensitivity, 0, 1); + const skewScale = THREE.MathUtils.lerp(0.06, 0.2, s); + const tiltScale = THREE.MathUtils.lerp(0.12, 0.3, s); + const yawScale = THREE.MathUtils.lerp(0.1, 0.28, s); + const depthResponse = THREE.MathUtils.lerp(0.25, 0.45, s); + const smoothTime = THREE.MathUtils.lerp(0.45, 0.12, s); + const maxSpeed = Infinity; + + const yBoost = THREE.MathUtils.lerp(1.2, 1.6, s); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + let leaveTimer = null; + const onMove = e => { + if (uiFaceActive) return; + if (leaveTimer) { + clearTimeout(leaveTimer); + leaveTimer = null; + } + const rect = el.getBoundingClientRect(); + const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1); + lookTarget.current.set(nx, ny); + }; + const onClick = async () => { + const nowSec = performance.now() / 1000; + if (scanOnClick) pushScan(nowSec); + if ( + enableGyro && + typeof window !== 'undefined' && + window.DeviceOrientationEvent && + DeviceOrientationEvent.requestPermission + ) { + try { + await DeviceOrientationEvent.requestPermission(); + } catch { + // noop + } + } + }; + const onEnter = () => { + if (leaveTimer) { + clearTimeout(leaveTimer); + leaveTimer = null; + } + }; + const onLeave = () => { + if (uiFaceActive) return; + if (leaveTimer) clearTimeout(leaveTimer); + leaveTimer = window.setTimeout( + () => { + lookTarget.current.set(0, 0); + tiltTarget.current = 0; + yawTarget.current = 0; + }, + Math.max(0, snapBackDelay || 0) + ); + }; + el.addEventListener('mousemove', onMove); + el.addEventListener('mouseenter', onEnter); + if (scanOnClick) el.addEventListener('click', onClick); + el.addEventListener('mouseleave', onLeave); + return () => { + el.removeEventListener('mousemove', onMove); + el.removeEventListener('mouseenter', onEnter); + el.removeEventListener('mouseleave', onLeave); + if (scanOnClick) el.removeEventListener('click', onClick); + if (leaveTimer) clearTimeout(leaveTimer); + }; + }, [uiFaceActive, snapBackDelay, scanOnClick, enableGyro]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + rendererRef.current = renderer; + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.NoToneMapping; + renderer.autoClear = false; + renderer.setClearColor(0x000000, 0); + container.appendChild(renderer.domElement); + + const uniforms = { + iResolution: { + value: new THREE.Vector3(container.clientWidth, container.clientHeight, renderer.getPixelRatio()) + }, + iTime: { value: 0 }, + uSkew: { value: new THREE.Vector2(0, 0) }, + uTilt: { value: 0 }, + uYaw: { value: 0 }, + uLineThickness: { value: lineThickness }, + uLinesColor: { value: srgbColor(linesColor) }, + uScanColor: { value: srgbColor(scanColor) }, + uGridScale: { value: gridScale }, + uLineStyle: { value: lineStyle === 'dashed' ? 1 : lineStyle === 'dotted' ? 2 : 0 }, + uLineJitter: { value: Math.max(0, Math.min(1, lineJitter || 0)) }, + uScanOpacity: { value: scanOpacity }, + uNoise: { value: noiseIntensity }, + uBloomOpacity: { value: bloomIntensity }, + uScanGlow: { value: scanGlow }, + uScanSoftness: { value: scanSoftness }, + uPhaseTaper: { value: scanPhaseTaper }, + uScanDuration: { value: scanDuration }, + uScanDelay: { value: scanDelay }, + uScanDirection: { value: scanDirection === 'backward' ? 1 : scanDirection === 'pingpong' ? 2 : 0 }, + uScanStarts: { value: new Array(MAX_SCANS).fill(0) }, + uScanCount: { value: 0 } + }; + + const material = new THREE.ShaderMaterial({ + uniforms, + vertexShader: vert, + fragmentShader: frag, + transparent: true, + depthWrite: false, + depthTest: false + }); + materialRef.current = material; + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material); + scene.add(quad); + + let composer = null; + if (enablePost) { + composer = new EffectComposer(renderer); + composerRef.current = composer; + const renderPass = new RenderPass(scene, camera); + composer.addPass(renderPass); + + const bloom = new BloomEffect({ + intensity: 1.0, + luminanceThreshold: bloomThreshold, + luminanceSmoothing: bloomSmoothing + }); + bloom.blendMode.opacity.value = Math.max(0, bloomIntensity); + bloomRef.current = bloom; + + const chroma = new ChromaticAberrationEffect({ + offset: new THREE.Vector2(chromaticAberration, chromaticAberration), + radialModulation: true, + modulationOffset: 0.0 + }); + chromaRef.current = chroma; + + const effectPass = new EffectPass(camera, bloom, chroma); + effectPass.renderToScreen = true; + composer.addPass(effectPass); + } + + const onResize = () => { + renderer.setSize(container.clientWidth, container.clientHeight); + material.uniforms.iResolution.value.set(container.clientWidth, container.clientHeight, renderer.getPixelRatio()); + if (composerRef.current) composerRef.current.setSize(container.clientWidth, container.clientHeight); + }; + window.addEventListener('resize', onResize); + + let last = performance.now(); + const tick = () => { + const now = performance.now(); + const dt = Math.max(0, Math.min(0.1, (now - last) / 1000)); + last = now; + + lookCurrent.current.copy( + smoothDampVec2(lookCurrent.current, lookTarget.current, lookVel.current, smoothTime, maxSpeed, dt) + ); + + const tiltSm = smoothDampFloat( + tiltCurrent.current, + tiltTarget.current, + { v: tiltVel.current }, + smoothTime, + maxSpeed, + dt + ); + tiltCurrent.current = tiltSm.value; + tiltVel.current = tiltSm.v; + + const yawSm = smoothDampFloat( + yawCurrent.current, + yawTarget.current, + { v: yawVel.current }, + smoothTime, + maxSpeed, + dt + ); + yawCurrent.current = yawSm.value; + yawVel.current = yawSm.v; + + const skew = new THREE.Vector2(lookCurrent.current.x * skewScale, -lookCurrent.current.y * yBoost * skewScale); + material.uniforms.uSkew.value.set(skew.x, skew.y); + material.uniforms.uTilt.value = tiltCurrent.current * tiltScale; + material.uniforms.uYaw.value = THREE.MathUtils.clamp(yawCurrent.current * yawScale, -0.6, 0.6); + + material.uniforms.iTime.value = now / 1000; + renderer.clear(true, true, true); + if (composerRef.current) { + composerRef.current.render(dt); + } else { + renderer.render(scene, camera); + } + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + window.removeEventListener('resize', onResize); + material.dispose(); + quad.geometry.dispose(); + + if (composerRef.current) { + composerRef.current.dispose(); + composerRef.current = null; + } + renderer.dispose(); + container.removeChild(renderer.domElement); + }; + }, [ + sensitivity, + lineThickness, + linesColor, + scanColor, + scanOpacity, + gridScale, + lineStyle, + lineJitter, + scanDirection, + enablePost, + noiseIntensity, + bloomIntensity, + scanGlow, + scanSoftness, + scanPhaseTaper, + scanDuration, + scanDelay, + bloomThreshold, + bloomSmoothing, + chromaticAberration, + smoothTime, + maxSpeed, + skewScale, + yBoost, + tiltScale, + yawScale + ]); + + useEffect(() => { + const m = materialRef.current; + if (m) { + const u = m.uniforms; + u.uLineThickness.value = lineThickness; + u.uLinesColor.value.copy(srgbColor(linesColor)); + u.uScanColor.value.copy(srgbColor(scanColor)); + u.uGridScale.value = gridScale; + u.uLineStyle.value = lineStyle === 'dashed' ? 1 : lineStyle === 'dotted' ? 2 : 0; + u.uLineJitter.value = Math.max(0, Math.min(1, lineJitter || 0)); + u.uBloomOpacity.value = Math.max(0, bloomIntensity); + u.uNoise.value = Math.max(0, noiseIntensity); + u.uScanGlow.value = scanGlow; + u.uScanOpacity.value = Math.max(0, Math.min(1, scanOpacity)); + u.uScanDirection.value = scanDirection === 'backward' ? 1 : scanDirection === 'pingpong' ? 2 : 0; + u.uScanSoftness.value = scanSoftness; + u.uPhaseTaper.value = scanPhaseTaper; + u.uScanDuration.value = Math.max(0.05, scanDuration); + u.uScanDelay.value = Math.max(0.0, scanDelay); + } + if (bloomRef.current) { + bloomRef.current.blendMode.opacity.value = Math.max(0, bloomIntensity); + bloomRef.current.luminanceMaterial.threshold = bloomThreshold; + bloomRef.current.luminanceMaterial.smoothing = bloomSmoothing; + } + if (chromaRef.current) { + chromaRef.current.offset.set(chromaticAberration, chromaticAberration); + } + }, [ + lineThickness, + linesColor, + scanColor, + gridScale, + lineStyle, + lineJitter, + bloomIntensity, + bloomThreshold, + bloomSmoothing, + chromaticAberration, + noiseIntensity, + scanGlow, + scanOpacity, + scanDirection, + scanSoftness, + scanPhaseTaper, + scanDuration, + scanDelay + ]); + + useEffect(() => { + if (!enableGyro) return; + const handler = e => { + if (uiFaceActive) return; + const gamma = e.gamma ?? 0; + const beta = e.beta ?? 0; + const nx = THREE.MathUtils.clamp(gamma / 45, -1, 1); + const ny = THREE.MathUtils.clamp(-beta / 30, -1, 1); + lookTarget.current.set(nx, ny); + tiltTarget.current = THREE.MathUtils.degToRad(gamma) * 0.4; + }; + window.addEventListener('deviceorientation', handler); + return () => { + window.removeEventListener('deviceorientation', handler); + }; + }, [enableGyro, uiFaceActive]); + + useEffect(() => { + let canceled = false; + const load = async () => { + try { + await Promise.all([ + faceapi.nets.tinyFaceDetector.loadFromUri(modelsPath), + faceapi.nets.faceLandmark68TinyNet.loadFromUri(modelsPath) + ]); + if (!canceled) setModelsReady(true); + } catch { + if (!canceled) setModelsReady(false); + } + }; + load(); + return () => { + canceled = true; + }; + }, [modelsPath]); + + useEffect(() => { + let stop = false; + let lastDetect = 0; + const video = videoRef.current; + + const start = async () => { + if (!enableWebcam || !modelsReady) return; + if (!video) return; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false + }); + video.srcObject = stream; + await video.play(); + } catch { + return; + } + + const opts = new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.5 }); + + const detect = async ts => { + if (stop) return; + + if (ts - lastDetect >= 33) { + lastDetect = ts; + try { + const res = await faceapi.detectSingleFace(video, opts).withFaceLandmarks(true); + if (res && res.detection) { + const det = res.detection; + const box = det.box; + const vw = video.videoWidth || 1; + const vh = video.videoHeight || 1; + + const cx = box.x + box.width * 0.5; + const cy = box.y + box.height * 0.5; + const nx = (cx / vw) * 2 - 1; + const ny = (cy / vh) * 2 - 1; + medianPush(bufX.current, nx, 5); + medianPush(bufY.current, ny, 5); + const nxm = median(bufX.current); + const nym = median(bufY.current); + + const look = new THREE.Vector2(Math.tanh(nxm), Math.tanh(nym)); + + const faceSize = Math.min(1, Math.hypot(box.width / vw, box.height / vh)); + const depthScale = 1 + depthResponse * (faceSize - 0.25); + lookTarget.current.copy(look.multiplyScalar(depthScale)); + + const leftEye = res.landmarks.getLeftEye(); + const rightEye = res.landmarks.getRightEye(); + const lc = centroid(leftEye); + const rc = centroid(rightEye); + const tilt = Math.atan2(rc.y - lc.y, rc.x - lc.x); + medianPush(bufT.current, tilt, 5); + tiltTarget.current = median(bufT.current); + + const nose = res.landmarks.getNose(); + const tip = nose[nose.length - 1] || nose[Math.floor(nose.length / 2)]; + const jaw = res.landmarks.getJawOutline(); + const leftCheek = jaw[3] || jaw[2]; + const rightCheek = jaw[13] || jaw[14]; + const dL = dist2(tip, leftCheek); + const dR = dist2(tip, rightCheek); + const eyeDist = Math.hypot(rc.x - lc.x, rc.y - lc.y) + 1e-6; + let yawSignal = THREE.MathUtils.clamp((dR - dL) / (eyeDist * 1.6), -1, 1); + yawSignal = Math.tanh(yawSignal); + medianPush(bufYaw.current, yawSignal, 5); + yawTarget.current = median(bufYaw.current); + + setUiFaceActive(true); + } else { + setUiFaceActive(false); + } + } catch { + setUiFaceActive(false); + } + } + + if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { + video.requestVideoFrameCallback(() => detect(performance.now())); + } else { + requestAnimationFrame(detect); + } + }; + + requestAnimationFrame(detect); + }; + + start(); + + return () => { + stop = true; + if (video) { + const stream = video.srcObject; + if (stream) stream.getTracks().forEach(t => t.stop()); + video.pause(); + video.srcObject = null; + } + }; + }, [enableWebcam, modelsReady, depthResponse]); + + return ( +
+ {showPreview && ( +
+
+ )} +
+ ); +}; + +function srgbColor(hex) { + const c = new THREE.Color(hex); + return c.convertSRGBToLinear(); +} + +function smoothDampVec2(current, target, currentVelocity, smoothTime, maxSpeed, deltaTime) { + const out = current.clone(); + smoothTime = Math.max(0.0001, smoothTime); + const omega = 2 / smoothTime; + const x = omega * deltaTime; + const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x); + + let change = current.clone().sub(target); + const originalTo = target.clone(); + + const maxChange = maxSpeed * smoothTime; + if (change.length() > maxChange) change.setLength(maxChange); + + target = current.clone().sub(change); + const temp = currentVelocity.clone().addScaledVector(change, omega).multiplyScalar(deltaTime); + currentVelocity.sub(temp.clone().multiplyScalar(omega)); + currentVelocity.multiplyScalar(exp); + + out.copy(target.clone().add(change.add(temp).multiplyScalar(exp))); + + const origMinusCurrent = originalTo.clone().sub(current); + const outMinusOrig = out.clone().sub(originalTo); + if (origMinusCurrent.dot(outMinusOrig) > 0) { + out.copy(originalTo); + currentVelocity.set(0, 0); + } + return out; +} + +function smoothDampFloat(current, target, velRef, smoothTime, maxSpeed, deltaTime) { + smoothTime = Math.max(0.0001, smoothTime); + const omega = 2 / smoothTime; + const x = omega * deltaTime; + const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x); + + let change = current - target; + const originalTo = target; + + const maxChange = maxSpeed * smoothTime; + change = Math.sign(change) * Math.min(Math.abs(change), maxChange); + + target = current - change; + const temp = (velRef.v + omega * change) * deltaTime; + velRef.v = (velRef.v - omega * temp) * exp; + + let out = target + (change + temp) * exp; + + const origMinusCurrent = originalTo - current; + const outMinusOrig = out - originalTo; + if (origMinusCurrent * outMinusOrig > 0) { + out = originalTo; + velRef.v = 0; + } + return { value: out, v: velRef.v }; +} + +function medianPush(buf, v, maxLen) { + buf.push(v); + if (buf.length > maxLen) buf.shift(); +} + +function median(buf) { + if (buf.length === 0) return 0; + const a = [...buf].sort((x, y) => x - y); + const mid = Math.floor(a.length / 2); + return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) * 0.5; +} + +function centroid(points) { + let x = 0, + y = 0; + const n = points.length || 1; + for (const p of points) { + x += p.x; + y += p.y; + } + return { x: x / n, y: y / n }; +} + +function dist2(a, b) { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +const GridScanComponent = () => { + return ( +
+ +
+ ); +}; + +export default GridScanComponent; \ No newline at end of file diff --git a/examples/react-bits-grid-scan-demo/requirements.txt b/examples/react-bits-grid-scan-demo/requirements.txt new file mode 100644 index 0000000..575eedf --- /dev/null +++ b/examples/react-bits-grid-scan-demo/requirements.txt @@ -0,0 +1,2 @@ +dash-vite-plugin>=0.1.3 +feffery-utils-components>=0.3.4 \ No newline at end of file diff --git a/examples/react-bits-lightning-demo/app.py b/examples/react-bits-lightning-demo/app.py new file mode 100644 index 0000000..9deaac5 --- /dev/null +++ b/examples/react-bits-lightning-demo/app.py @@ -0,0 +1,38 @@ +import dash +from dash import html +import feffery_utils_components as fuc +from dash_vite_plugin import VitePlugin, NpmPackage +from dash.dependencies import Input, State, ClientsideFunction + +vite_plugin = VitePlugin( + build_assets_paths=['assets'], + entry_js_paths=['assets/callback.js'], + npm_packages=[ + NpmPackage('react'), + NpmPackage('react-dom'), + ], + download_node=True, + clean_after=False, # 开发阶段建议设置为False以加速构建 +) + +vite_plugin.setup() + +app = dash.Dash(__name__) + +vite_plugin.use(app) + +app.layout = html.Div( + [ + fuc.FefferyTimeout(id='trigger-background-init', delay=100), + html.Div(id='background-mount'), + ] +) + +app.clientside_callback( + ClientsideFunction(namespace='clientside', function_name='renderBackground'), + Input('trigger-background-init', 'timeoutCount'), + State('background-mount', 'id'), +) + +if __name__ == '__main__': + app.run(debug=True) diff --git a/examples/react-bits-lightning-demo/assets/callback.js b/examples/react-bits-lightning-demo/assets/callback.js new file mode 100644 index 0000000..d47fbcf --- /dev/null +++ b/examples/react-bits-lightning-demo/assets/callback.js @@ -0,0 +1,15 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import LightningComponent from "./react_components/Lightning.jsx"; + +window.dash_clientside = Object.assign({}, window.dash_clientside, { + clientside: { + renderBackground: (timeoutCount, id) => { + const container = document.getElementById(id); + const root = createRoot(container); + root.render(React.createElement(LightningComponent)); + + return window.dash_clientside.no_update; + }, + }, +}); diff --git a/examples/react-bits-lightning-demo/assets/react_components/Lightning.css b/examples/react-bits-lightning-demo/assets/react_components/Lightning.css new file mode 100644 index 0000000..4e62440 --- /dev/null +++ b/examples/react-bits-lightning-demo/assets/react_components/Lightning.css @@ -0,0 +1,7 @@ +.lightning-container { + width: 100vw; + height: 100vh; + top: 0; + left: 0; + position: fixed; +} \ No newline at end of file diff --git a/examples/react-bits-lightning-demo/assets/react_components/Lightning.jsx b/examples/react-bits-lightning-demo/assets/react_components/Lightning.jsx new file mode 100644 index 0000000..d084abf --- /dev/null +++ b/examples/react-bits-lightning-demo/assets/react_components/Lightning.jsx @@ -0,0 +1,193 @@ +import { useRef, useEffect } from 'react'; +import './Lightning.css'; + +const Lightning = ({ hue = 230, xOffset = 0, speed = 1, intensity = 1, size = 1 }) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resizeCanvas = () => { + canvas.width = canvas.clientWidth; + canvas.height = canvas.clientHeight; + }; + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + + const gl = canvas.getContext('webgl'); + if (!gl) { + console.error('WebGL not supported'); + return; + } + + const vertexShaderSource = ` + attribute vec2 aPosition; + void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); + } + `; + + const fragmentShaderSource = ` + precision mediump float; + uniform vec2 iResolution; + uniform float iTime; + uniform float uHue; + uniform float uXOffset; + uniform float uSpeed; + uniform float uIntensity; + uniform float uSize; + + #define OCTAVE_COUNT 10 + + vec3 hsv2rgb(vec3 c) { + vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0,4.0,2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + return c.z * mix(vec3(1.0), rgb, c.y); + } + + float hash11(float p) { + p = fract(p * .1031); + p *= p + 33.33; + p *= p + p; + return fract(p); + } + + float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * .1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + mat2 rotate2d(float theta) { + float c = cos(theta); + float s = sin(theta); + return mat2(c, -s, s, c); + } + + float noise(vec2 p) { + vec2 ip = floor(p); + vec2 fp = fract(p); + float a = hash12(ip); + float b = hash12(ip + vec2(1.0, 0.0)); + float c = hash12(ip + vec2(0.0, 1.0)); + float d = hash12(ip + vec2(1.0, 1.0)); + + vec2 t = smoothstep(0.0, 1.0, fp); + return mix(mix(a, b, t.x), mix(c, d, t.x), t.y); + } + + float fbm(vec2 p) { + float value = 0.0; + float amplitude = 0.5; + for (int i = 0; i < OCTAVE_COUNT; ++i) { + value += amplitude * noise(p); + p *= rotate2d(0.45); + p *= 2.0; + amplitude *= 0.5; + } + return value; + } + + void mainImage( out vec4 fragColor, in vec2 fragCoord ) { + vec2 uv = fragCoord / iResolution.xy; + uv = 2.0 * uv - 1.0; + uv.x *= iResolution.x / iResolution.y; + uv.x += uXOffset; + + uv += 2.0 * fbm(uv * uSize + 0.8 * iTime * uSpeed) - 1.0; + + float dist = abs(uv.x); + vec3 baseColor = hsv2rgb(vec3(uHue / 360.0, 0.7, 0.8)); + vec3 col = baseColor * pow(mix(0.0, 0.07, hash11(iTime * uSpeed)) / dist, 1.0) * uIntensity; + col = pow(col, vec3(1.0)); + fragColor = vec4(col, 1.0); + } + + void main() { + mainImage(gl_FragColor, gl_FragCoord.xy); + } + `; + + const compileShader = (source, type) => { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('Shader compile error:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + return shader; + }; + + const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER); + const fragmentShader = compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER); + if (!vertexShader || !fragmentShader) return; + + const program = gl.createProgram(); + if (!program) return; + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error('Program linking error:', gl.getProgramInfoLog(program)); + return; + } + gl.useProgram(program); + + const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]); + const vertexBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + + const aPosition = gl.getAttribLocation(program, 'aPosition'); + gl.enableVertexAttribArray(aPosition); + gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0); + + const iResolutionLocation = gl.getUniformLocation(program, 'iResolution'); + const iTimeLocation = gl.getUniformLocation(program, 'iTime'); + const uHueLocation = gl.getUniformLocation(program, 'uHue'); + const uXOffsetLocation = gl.getUniformLocation(program, 'uXOffset'); + const uSpeedLocation = gl.getUniformLocation(program, 'uSpeed'); + const uIntensityLocation = gl.getUniformLocation(program, 'uIntensity'); + const uSizeLocation = gl.getUniformLocation(program, 'uSize'); + + const startTime = performance.now(); + const render = () => { + resizeCanvas(); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.uniform2f(iResolutionLocation, canvas.width, canvas.height); + const currentTime = performance.now(); + gl.uniform1f(iTimeLocation, (currentTime - startTime) / 1000.0); + gl.uniform1f(uHueLocation, hue); + gl.uniform1f(uXOffsetLocation, xOffset); + gl.uniform1f(uSpeedLocation, speed); + gl.uniform1f(uIntensityLocation, intensity); + gl.uniform1f(uSizeLocation, size); + gl.drawArrays(gl.TRIANGLES, 0, 6); + requestAnimationFrame(render); + }; + requestAnimationFrame(render); + + return () => { + window.removeEventListener('resize', resizeCanvas); + }; + }, [hue, xOffset, speed, intensity, size]); + + return ; +}; + +const LightningComponent = () => { + return ( + + ); +}; + +export default LightningComponent; \ No newline at end of file diff --git a/examples/react-bits-lightning-demo/requirements.txt b/examples/react-bits-lightning-demo/requirements.txt new file mode 100644 index 0000000..575eedf --- /dev/null +++ b/examples/react-bits-lightning-demo/requirements.txt @@ -0,0 +1,2 @@ +dash-vite-plugin>=0.1.3 +feffery-utils-components>=0.3.4 \ No newline at end of file diff --git a/examples/react-bits-shiny-text-demo/app.py b/examples/react-bits-shiny-text-demo/app.py new file mode 100644 index 0000000..09d3ca5 --- /dev/null +++ b/examples/react-bits-shiny-text-demo/app.py @@ -0,0 +1,53 @@ +import dash +from dash import html +import feffery_antd_components as fac +import feffery_utils_components as fuc +from feffery_dash_utils.style_utils import style +from dash_vite_plugin import VitePlugin, NpmPackage +from dash.dependencies import Input, State, ClientsideFunction + + +vite_plugin = VitePlugin( + build_assets_paths=['assets'], + entry_js_paths=['assets/callback.js'], + npm_packages=[ + NpmPackage('react'), + NpmPackage('react-dom'), + NpmPackage('motion'), + ], + download_node=True, + clean_after=False, # 开发阶段建议设置为False以加速构建 +) + +vite_plugin.setup() + +app = dash.Dash(__name__) + +vite_plugin.use(app) + +app.layout = html.Div( + [ + fuc.FefferyTimeout(id='trigger-text-init', delay=100), + fac.AntdCenter( + id='text-mount', + style=style( + position='fixed', + width='100vw', + height='100vh', + left=0, + top=0, + background='#060010', + fontSize=56, + ), + ), + ] +) + +app.clientside_callback( + ClientsideFunction(namespace='clientside', function_name='renderText'), + Input('trigger-text-init', 'timeoutCount'), + State('text-mount', 'id'), +) + +if __name__ == '__main__': + app.run(debug=True) diff --git a/examples/react-bits-shiny-text-demo/assets/callback.js b/examples/react-bits-shiny-text-demo/assets/callback.js new file mode 100644 index 0000000..1ef4ad7 --- /dev/null +++ b/examples/react-bits-shiny-text-demo/assets/callback.js @@ -0,0 +1,15 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import ShinyTextComponent from "./react_components/ShinyText.jsx"; + +window.dash_clientside = Object.assign({}, window.dash_clientside, { + clientside: { + renderText: (timeoutCount, id) => { + const container = document.getElementById(id); + const root = createRoot(container); + root.render(React.createElement(ShinyTextComponent)); + + return window.dash_clientside.no_update; + }, + }, +}); diff --git a/examples/react-bits-shiny-text-demo/assets/react_components/ShinyText.css b/examples/react-bits-shiny-text-demo/assets/react_components/ShinyText.css new file mode 100644 index 0000000..9d07f2d --- /dev/null +++ b/examples/react-bits-shiny-text-demo/assets/react_components/ShinyText.css @@ -0,0 +1,3 @@ +.shiny-text { + display: inline-block; +} \ No newline at end of file diff --git a/examples/react-bits-shiny-text-demo/assets/react_components/ShinyText.jsx b/examples/react-bits-shiny-text-demo/assets/react_components/ShinyText.jsx new file mode 100644 index 0000000..a5fc9e7 --- /dev/null +++ b/examples/react-bits-shiny-text-demo/assets/react_components/ShinyText.jsx @@ -0,0 +1,133 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react'; +import './ShinyText.css'; + +const ShinyText = ({ + text, + disabled = false, + speed = 2, + className = '', + color = '#b5b5b5', + shineColor = '#ffffff', + spread = 120, + yoyo = false, + pauseOnHover = false, + direction = 'left', + delay = 0 +}) => { + const [isPaused, setIsPaused] = useState(false); + const progress = useMotionValue(0); + const elapsedRef = useRef(0); + const lastTimeRef = useRef(null); + const directionRef = useRef(direction === 'left' ? 1 : -1); + + const animationDuration = speed * 1000; + const delayDuration = delay * 1000; + + useAnimationFrame(time => { + if (disabled || isPaused) { + lastTimeRef.current = null; + return; + } + + if (lastTimeRef.current === null) { + lastTimeRef.current = time; + return; + } + + const deltaTime = time - lastTimeRef.current; + lastTimeRef.current = time; + + elapsedRef.current += deltaTime; + + if (yoyo) { + const cycleDuration = animationDuration + delayDuration; + const fullCycle = cycleDuration * 2; + const cycleTime = elapsedRef.current % fullCycle; + + if (cycleTime < animationDuration) { + // Forward animation: 0 -> 100 + const p = (cycleTime / animationDuration) * 100; + progress.set(directionRef.current === 1 ? p : 100 - p); + } else if (cycleTime < cycleDuration) { + // Delay at end + progress.set(directionRef.current === 1 ? 100 : 0); + } else if (cycleTime < cycleDuration + animationDuration) { + // Reverse animation: 100 -> 0 + const reverseTime = cycleTime - cycleDuration; + const p = 100 - (reverseTime / animationDuration) * 100; + progress.set(directionRef.current === 1 ? p : 100 - p); + } else { + // Delay at start + progress.set(directionRef.current === 1 ? 0 : 100); + } + } else { + const cycleDuration = animationDuration + delayDuration; + const cycleTime = elapsedRef.current % cycleDuration; + + if (cycleTime < animationDuration) { + // Animation phase: 0 -> 100 + const p = (cycleTime / animationDuration) * 100; + progress.set(directionRef.current === 1 ? p : 100 - p); + } else { + // Delay phase - hold at end (shine off-screen) + progress.set(directionRef.current === 1 ? 100 : 0); + } + } + }); + + useEffect(() => { + directionRef.current = direction === 'left' ? 1 : -1; + elapsedRef.current = 0; + progress.set(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [direction]); + + // Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left) + const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`); + + const handleMouseEnter = useCallback(() => { + if (pauseOnHover) setIsPaused(true); + }, [pauseOnHover]); + + const handleMouseLeave = useCallback(() => { + if (pauseOnHover) setIsPaused(false); + }, [pauseOnHover]); + + const gradientStyle = { + backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`, + backgroundSize: '200% auto', + WebkitBackgroundClip: 'text', + backgroundClip: 'text', + WebkitTextFillColor: 'transparent' + }; + + return ( + + {text} + + ); +}; + +const ShinyTextComponent = () => { + return ( + + ); +}; + +export default ShinyTextComponent; \ No newline at end of file diff --git a/examples/react-bits-shiny-text-demo/requirements.txt b/examples/react-bits-shiny-text-demo/requirements.txt new file mode 100644 index 0000000..ca1fd63 --- /dev/null +++ b/examples/react-bits-shiny-text-demo/requirements.txt @@ -0,0 +1,4 @@ +dash-vite-plugin>=0.1.3 +feffery-antd-components>=0.4.2 +feffery-dash-utils>=0.2.6 +feffery-utils-components>=0.3.4 \ No newline at end of file diff --git a/examples/vue-basic-demo/app.py b/examples/vue-basic-demo/app.py new file mode 100644 index 0000000..7704a48 --- /dev/null +++ b/examples/vue-basic-demo/app.py @@ -0,0 +1,76 @@ +import dash +from dash import html +from dash.dependencies import Input, Output +from dash_vite_plugin import VitePlugin, NpmPackage + +# Create VitePlugin instance with Vue support +vite_plugin = VitePlugin( + build_assets_paths=['assets/js', 'assets/vue'], + entry_js_paths=['assets/js/main.js'], + npm_packages=[ + NpmPackage('vue'), + ], + download_node=True, + clean_after=True, +) + +# Call setup BEFORE creating Dash app (as required by the plugin architecture) +vite_plugin.setup() + +# Create a Dash app +app = dash.Dash(__name__) + +# Call use AFTER creating Dash app (as required by the plugin architecture) +vite_plugin.use(app) + +# Define app layout with a container for Vue +app.layout = html.Div( + [ + html.H1('Vite Plugin Test - Vue Support', id='header'), + html.P('This tests the Vite plugin with Vue support.', id='paragraph'), + # Container for Vue app + html.Div( + [ + 'The content from Vue', + html.Div(id='vue-container'), + ] + ), + html.Div( + [ + 'The content from Dash', + html.Div( + [html.H1('Hello from Dash!', id='dash-title'), html.Button('Control Vue', id='dash-button')], + id='dash-app', + style={'margin': '20px'}, + ), + ], + id='dash-container', + ), + ] +) + + +# Add callback to test Vue functionality with a simpler approach +app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks > 0) { + const vueApp = document.getElementById('vue-app'); + if (vueApp) { + const button = vueApp.querySelector('#control-vue-button'); + if (button) { + button.click(); + return 'Hello from Dash!'; + } + } + } + return 'Hello from Dash!'; + } + """, + Output('dash-title', 'children'), + Input('dash-button', 'n_clicks'), +) + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/examples/vue-basic-demo/assets/js/main.js b/examples/vue-basic-demo/assets/js/main.js new file mode 100644 index 0000000..c92a342 --- /dev/null +++ b/examples/vue-basic-demo/assets/js/main.js @@ -0,0 +1,39 @@ +import { createApp } from "vue"; +import App from "../vue/App.vue"; + +// Add a global variable to test JavaScript execution +window.testVariable = "VitePluginVueTest"; + +// Mount the Vue app +function waitForElement(selector) { + return new Promise((resolve) => { + const element = document.querySelector(selector); + if (element) { + resolve(element); + return; + } + + const observer = new MutationObserver((mutations) => { + const targetElement = document.querySelector(selector); + if (targetElement) { + observer.disconnect(); + resolve(targetElement); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); +} + +// Wait for the mount point to appear and then create the application +waitForElement("#vue-container") + .then((element) => { + const app = createApp(App); + app.mount(element); + }) + .catch((error) => { + console.error("Unable to find mount point:", error); + }); diff --git a/examples/vue-basic-demo/assets/vue/App.vue b/examples/vue-basic-demo/assets/vue/App.vue new file mode 100644 index 0000000..9c816bf --- /dev/null +++ b/examples/vue-basic-demo/assets/vue/App.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/examples/vue-basic-demo/requirements.txt b/examples/vue-basic-demo/requirements.txt new file mode 100644 index 0000000..5f7b3fb --- /dev/null +++ b/examples/vue-basic-demo/requirements.txt @@ -0,0 +1 @@ +dash-vite-plugin>=0.1.3 \ No newline at end of file