From 00d98879d35e52b4025f78fd7214c80cd280bcfd Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis Date: Thu, 8 Jan 2026 19:23:49 +0100 Subject: [PATCH] Initial iris repository structure WASM reactive UI framework for Go: - reactive/ - Signal[T], Effect, Runtime - ui/ - Button, Text, Input, View, Canvas, SVG components - navigation/ - Router, guards, history management - auth/ - OIDC client for WASM applications - host/ - Static file server Extracted from arcadia as open-source component. Co-Authored-By: Claude --- .gitea/workflows/ci.yaml | 21 + .gitignore | 17 + CLAUDE.md | 101 ++++ LICENSE | 190 ++++++ Makefile | 16 + auth/http_wasm.go | 195 ++++++ auth/oidc.go | 306 ++++++++++ auth/types.go | 30 + go.mod | 3 + host/server.go | 150 +++++ internal/element/element.go | 283 +++++++++ navigation/components.go | 90 +++ navigation/core.go | 69 +++ navigation/guards.go | 91 +++ navigation/history.go | 63 ++ navigation/router.go | 157 +++++ reactive/effect.go | 18 + reactive/runtime.go | 39 ++ reactive/signal.go | 52 ++ ui/app.go | 15 + ui/app_router.go | 29 + ui/button.go | 9 + ui/canvas.go | 1124 +++++++++++++++++++++++++++++++++++ ui/canvas_config.go | 51 ++ ui/canvas_errors.go | 36 ++ ui/canvas_geometry.go | 144 +++++ ui/font.go | 42 ++ ui/image.go | 7 + ui/input.go | 208 +++++++ ui/layout.go | 90 +++ ui/modifiers.go | 172 ++++++ ui/svg.go | 264 ++++++++ ui/text.go | 23 + ui/utils.go | 10 + ui/view.go | 29 + vision.md | 37 ++ 36 files changed, 4181 insertions(+) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 auth/http_wasm.go create mode 100644 auth/oidc.go create mode 100644 auth/types.go create mode 100644 go.mod create mode 100644 host/server.go create mode 100644 internal/element/element.go create mode 100644 navigation/components.go create mode 100644 navigation/core.go create mode 100644 navigation/guards.go create mode 100644 navigation/history.go create mode 100644 navigation/router.go create mode 100644 reactive/effect.go create mode 100644 reactive/runtime.go create mode 100644 reactive/signal.go create mode 100644 ui/app.go create mode 100644 ui/app_router.go create mode 100644 ui/button.go create mode 100644 ui/canvas.go create mode 100644 ui/canvas_config.go create mode 100644 ui/canvas_errors.go create mode 100644 ui/canvas_geometry.go create mode 100644 ui/font.go create mode 100644 ui/image.go create mode 100644 ui/input.go create mode 100644 ui/layout.go create mode 100644 ui/modifiers.go create mode 100644 ui/svg.go create mode 100644 ui/text.go create mode 100644 ui/utils.go create mode 100644 ui/view.go create mode 100644 vision.md diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..3f59936 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,21 @@ +name: CI +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - name: Build WASM + run: GOOS=js GOARCH=wasm go build ./... + - name: Build Host + run: go build ./host/... + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caf03b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +*.wasm +/dist/ +/build/ +/bin/ + +# Go +/vendor/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3abda5b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# Iris + +WASM reactive UI framework for Go. + +## Organization Context + +This repo is part of Flowmade. See: +- [Organization manifesto](../architecture/manifesto.md) - who we are, what we believe +- [Repository map](../architecture/repos.md) - how this fits in the bigger picture +- [Vision](./vision.md) - what this specific product does + +## Setup + +```bash +git clone git@git.flowmade.one:flowmade-one/iris.git +cd iris +``` + +## Project Structure + +``` +iris/ +├── reactive/ # Signal[T], Effect, Runtime - core reactivity +├── ui/ # Components: Button, Text, Input, View, Canvas, SVG +├── navigation/ # Router, guards, history management +├── auth/ # OIDC client for WASM applications +├── host/ # Static file server for WASM apps +└── internal/ # Internal element abstraction +``` + +## Development + +```bash +# Build (WASM target) +GOOS=js GOARCH=wasm go build ./... + +# Build host server (native) +go build ./host/... + +# Test +go test ./... + +# Lint +golangci-lint run +``` + +## Architecture + +### Reactivity Model + +Iris uses a signals-based reactivity model: + +```go +// Create a signal +count := reactive.NewSignal(0) + +// Read value +fmt.Println(count.Get()) + +// Update value - triggers dependent effects +count.Set(count.Get() + 1) + +// Create derived state +doubled := reactive.Computed(func() int { + return count.Get() * 2 +}) +``` + +### Components + +All UI components implement a common interface and render to DOM elements: + +```go +// Create a button +btn := ui.Button("Click me", func() { + count.Set(count.Get() + 1) +}) + +// Create reactive text +text := ui.Text(func() string { + return fmt.Sprintf("Count: %d", count.Get()) +}) +``` + +### Routing + +Client-side routing with guards: + +```go +router := navigation.NewRouter() +router.Route("/", homeView) +router.Route("/users/:id", userView) +router.Guard("/admin/*", authGuard) +``` + +## Key Patterns + +- **No virtual DOM** - Direct DOM manipulation via syscall/js +- **Signals propagate** - Changes flow through the dependency graph +- **Components are functions** - Return DOM elements, not component instances +- **WASM-only UI code** - Use build tags for browser-specific code diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..479e231 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024-2026 Flowmade + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4845868 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: build build-wasm test lint clean + +build: build-wasm + go build ./host/... + +build-wasm: + GOOS=js GOARCH=wasm go build ./... + +test: + go test ./... + +lint: + golangci-lint run + +clean: + rm -f *.wasm diff --git a/auth/http_wasm.go b/auth/http_wasm.go new file mode 100644 index 0000000..cb4c141 --- /dev/null +++ b/auth/http_wasm.go @@ -0,0 +1,195 @@ +//go:build js && wasm + +package auth + +import ( + "encoding/json" + "fmt" + "net/url" + "syscall/js" +) + +// WASMHTTPClient handles HTTP requests in WASM environment +type WASMHTTPClient struct{} + +// HTTPResult represents the result of an HTTP request +type HTTPResult struct { + Data []byte + Error error +} + +// HTTPCallback is called when HTTP request completes +type HTTPCallback func(result HTTPResult) + +// FetchJSON performs a GET request and unmarshals JSON response +// This method blocks and should only be called from the main thread +func (c *WASMHTTPClient) FetchJSON(url string, dest interface{}) error { + result := make(chan HTTPResult, 1) + + c.FetchJSONAsync(url, func(r HTTPResult) { + result <- r + }) + + r := <-result + if r.Error != nil { + return r.Error + } + + return json.Unmarshal(r.Data, dest) +} + +// FetchJSONAsync performs a GET request asynchronously using callback +func (c *WASMHTTPClient) FetchJSONAsync(url string, callback HTTPCallback) { + // Create fetch promise + promise := js.Global().Call("fetch", url) + + // Success handler + var successFunc js.Func + successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + response := args[0] + if !response.Get("ok").Bool() { + callback(HTTPResult{ + Error: fmt.Errorf("HTTP %d: %s", response.Get("status").Int(), response.Get("statusText").String()), + }) + successFunc.Release() + return nil + } + + // Get response text + textPromise := response.Call("text") + var textFunc js.Func + textFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + text := args[0].String() + callback(HTTPResult{ + Data: []byte(text), + }) + // Cleanup after callback completes + textFunc.Release() + successFunc.Release() + return nil + }) + + // Error handler for text promise + var textErrorFunc js.Func + textErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + callback(HTTPResult{ + Error: fmt.Errorf("failed to read response text: %v", args[0]), + }) + // Cleanup + textFunc.Release() + textErrorFunc.Release() + successFunc.Release() + return nil + }) + + textPromise.Call("then", textFunc).Call("catch", textErrorFunc) + return nil + }) + + // Error handler + var errorFunc js.Func + errorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + callback(HTTPResult{ + Error: fmt.Errorf("fetch error: %s", args[0].String()), + }) + // Cleanup after callback completes + errorFunc.Release() + successFunc.Release() + return nil + }) + + promise.Call("then", successFunc).Call("catch", errorFunc) +} + +// PostForm performs a POST request with form data +func (c *WASMHTTPClient) PostForm(url string, data url.Values, dest interface{}) error { + result := make(chan HTTPResult, 1) + + c.PostFormAsync(url, data, func(r HTTPResult) { + result <- r + }) + + r := <-result + if r.Error != nil { + return r.Error + } + + return json.Unmarshal(r.Data, dest) +} + +// PostFormAsync performs a POST request asynchronously using callback +func (c *WASMHTTPClient) PostFormAsync(url string, data url.Values, callback HTTPCallback) { + // Prepare fetch options + headers := map[string]interface{}{ + "Content-Type": "application/x-www-form-urlencoded", + } + + options := map[string]interface{}{ + "method": "POST", + "headers": headers, + "body": data.Encode(), + } + + // Convert options to JS object + jsOptions := js.ValueOf(options) + + // Make the request + promise := js.Global().Call("fetch", url, jsOptions) + + // Success handler + var successFunc js.Func + successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + response := args[0] + if !response.Get("ok").Bool() { + callback(HTTPResult{ + Error: fmt.Errorf("HTTP %d: %s", response.Get("status").Int(), response.Get("statusText").String()), + }) + successFunc.Release() + return nil + } + + // Get response text + textPromise := response.Call("text") + var textFunc js.Func + textFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + text := args[0].String() + callback(HTTPResult{ + Data: []byte(text), + }) + // Cleanup after callback completes + textFunc.Release() + successFunc.Release() + return nil + }) + + // Error handler for text promise + var textErrorFunc js.Func + textErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + callback(HTTPResult{ + Error: fmt.Errorf("failed to read response text: %v", args[0]), + }) + // Cleanup + textFunc.Release() + textErrorFunc.Release() + successFunc.Release() + return nil + }) + + textPromise.Call("then", textFunc).Call("catch", textErrorFunc) + return nil + }) + + // Error handler + var errorFunc js.Func + errorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + callback(HTTPResult{ + Error: fmt.Errorf("fetch error: %s", args[0].String()), + }) + // Cleanup after callback completes + errorFunc.Release() + successFunc.Release() + return nil + }) + + promise.Call("then", successFunc).Call("catch", errorFunc) +} diff --git a/auth/oidc.go b/auth/oidc.go new file mode 100644 index 0000000..2fed047 --- /dev/null +++ b/auth/oidc.go @@ -0,0 +1,306 @@ +//go:build js && wasm + +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "strings" + "syscall/js" + "time" +) + +// OIDC types are defined in types.go + +// OIDCClient handles OIDC authentication flows +type OIDCClient struct { + config *OIDCConfig + clientID string + redirectURI string + scopes []string + + // Browser storage + localStorage js.Value + sessionStorage js.Value + + // HTTP client for WASM + httpClient *WASMHTTPClient +} + +// Token and user info types are defined in types.go + +// NewOIDCClient creates a new OIDC client +func NewOIDCClient(issuer, clientID, redirectURI string) *OIDCClient { + client := &OIDCClient{ + clientID: clientID, + redirectURI: redirectURI, + scopes: []string{"openid", "email", "profile"}, + localStorage: js.Global().Get("localStorage"), + sessionStorage: js.Global().Get("sessionStorage"), + httpClient: &WASMHTTPClient{}, + } + + return client +} + +// DiscoverConfig fetches the OIDC discovery configuration synchronously +func (c *OIDCClient) DiscoverConfig(issuer string) error { + discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + + // Use fetch to get the configuration + return c.fetchJSON(discoveryURL, &c.config) +} + +// DiscoverConfigAsync fetches the OIDC discovery configuration asynchronously +func (c *OIDCClient) DiscoverConfigAsync(issuer string, callback func(error)) { + discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + + c.httpClient.FetchJSONAsync(discoveryURL, func(result HTTPResult) { + if result.Error != nil { + callback(result.Error) + return + } + + // Parse the configuration + var config OIDCConfig + if err := json.Unmarshal(result.Data, &config); err != nil { + callback(fmt.Errorf("failed to parse OIDC config: %w", err)) + return + } + + c.config = &config + callback(nil) + }) +} + +// EnsureConfigLoaded ensures OIDC configuration is loaded before proceeding +func (c *OIDCClient) EnsureConfigLoaded(issuer string) error { + if c.config != nil { + return nil + } + return c.DiscoverConfig(issuer) +} + +// StartAuthFlow initiates the OIDC authentication flow +func (c *OIDCClient) StartAuthFlow() error { + if c.config == nil { + return fmt.Errorf("OIDC configuration not loaded - call DiscoverConfig first") + } + + // Generate PKCE parameters + verifier, err := c.generateCodeVerifier() + if err != nil { + return fmt.Errorf("failed to generate code verifier: %w", err) + } + + challenge := c.generateCodeChallenge(verifier) + state := c.generateState() + + // Store PKCE verifier and state in session storage + c.sessionStorage.Call("setItem", "pkce_verifier", verifier) + c.sessionStorage.Call("setItem", "auth_state", state) + + // Build authorization URL + authURL, err := c.buildAuthURL(challenge, state) + if err != nil { + return fmt.Errorf("failed to build auth URL: %w", err) + } + + // Redirect to Dex + js.Global().Get("window").Get("location").Set("href", authURL) + return nil +} + +// HandleCallback processes the OAuth callback +func (c *OIDCClient) HandleCallback(issuer string) (*TokenResponse, error) { + // Ensure configuration is loaded before processing + if err := c.EnsureConfigLoaded(issuer); err != nil { + return nil, fmt.Errorf("failed to load OIDC configuration: %w", err) + } + + // Get current URL parameters + urlParams := c.getURLParams() + + code := urlParams.Get("code") + state := urlParams.Get("state") + errorParam := urlParams.Get("error") + + if errorParam != "" { + return nil, fmt.Errorf("OAuth error: %s", errorParam) + } + + if code == "" { + return nil, fmt.Errorf("authorization code not found in callback") + } + + // Verify state parameter + storedState := c.sessionStorage.Call("getItem", "auth_state").String() + if state != storedState { + return nil, fmt.Errorf("invalid state parameter") + } + + // Get PKCE verifier + verifier := c.sessionStorage.Call("getItem", "pkce_verifier").String() + if verifier == "" { + return nil, fmt.Errorf("PKCE verifier not found") + } + + // Exchange code for tokens + return c.exchangeCodeForTokens(code, verifier) +} + +// GetStoredTokens retrieves tokens from local storage +func (c *OIDCClient) GetStoredTokens() *TokenResponse { + accessToken := c.localStorage.Call("getItem", "access_token") + idToken := c.localStorage.Call("getItem", "id_token") + refreshToken := c.localStorage.Call("getItem", "refresh_token") + + if accessToken.IsNull() || idToken.IsNull() { + return nil + } + + return &TokenResponse{ + AccessToken: accessToken.String(), + IDToken: idToken.String(), + RefreshToken: refreshToken.String(), + TokenType: "Bearer", + } +} + +// StoreTokens saves tokens to local storage +func (c *OIDCClient) StoreTokens(tokens *TokenResponse) { + c.localStorage.Call("setItem", "access_token", tokens.AccessToken) + c.localStorage.Call("setItem", "id_token", tokens.IDToken) + if tokens.RefreshToken != "" { + c.localStorage.Call("setItem", "refresh_token", tokens.RefreshToken) + } + + // Store expiration time + if tokens.ExpiresIn > 0 { + expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second) + c.localStorage.Call("setItem", "token_expires_at", expiresAt.Unix()) + } +} + +// ClearTokens removes all stored tokens +func (c *OIDCClient) ClearTokens() { + c.localStorage.Call("removeItem", "access_token") + c.localStorage.Call("removeItem", "id_token") + c.localStorage.Call("removeItem", "refresh_token") + c.localStorage.Call("removeItem", "token_expires_at") +} + +// IsAuthenticated checks if the user has valid tokens +func (c *OIDCClient) IsAuthenticated() bool { + tokens := c.GetStoredTokens() + if tokens == nil { + return false + } + + // Check if token is expired + expiresAtStr := c.localStorage.Call("getItem", "token_expires_at") + if !expiresAtStr.IsNull() { + // localStorage returns strings, need to convert to int + expiresAtString := expiresAtStr.String() + var expiresAt int64 + if _, err := fmt.Sscanf(expiresAtString, "%d", &expiresAt); err == nil { + if time.Now().Unix() > expiresAt { + return false + } + } + } + + return tokens.AccessToken != "" && tokens.IDToken != "" +} + +// Logout clears all authentication data +func (c *OIDCClient) Logout() { + c.ClearTokens() + c.sessionStorage.Call("removeItem", "pkce_verifier") + c.sessionStorage.Call("removeItem", "auth_state") +} + +// GetAuthHeader returns the Authorization header value +func (c *OIDCClient) GetAuthHeader() string { + tokens := c.GetStoredTokens() + if tokens == nil { + return "" + } + return "Bearer " + tokens.IDToken +} + +// Helper methods + +func (c *OIDCClient) generateCodeVerifier() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +func (c *OIDCClient) generateCodeChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func (c *OIDCClient) generateState() string { + bytes := make([]byte, 16) + rand.Read(bytes) + return base64.RawURLEncoding.EncodeToString(bytes) +} + +func (c *OIDCClient) buildAuthURL(challenge, state string) (string, error) { + params := url.Values{ + "client_id": {c.clientID}, + "response_type": {"code"}, + "scope": {strings.Join(c.scopes, " ")}, + "redirect_uri": {c.redirectURI}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + } + + authURL := c.config.AuthURL + "?" + params.Encode() + return authURL, nil +} + +func (c *OIDCClient) getURLParams() url.Values { + location := js.Global().Get("window").Get("location") + search := location.Get("search").String() + params, _ := url.ParseQuery(strings.TrimPrefix(search, "?")) + return params +} + +func (c *OIDCClient) exchangeCodeForTokens(code, verifier string) (*TokenResponse, error) { + // Prepare form data + data := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {c.clientID}, + "code": {code}, + "redirect_uri": {c.redirectURI}, + "code_verifier": {verifier}, + } + + // Make POST request to token endpoint + var tokens TokenResponse + if err := c.postForm(c.config.TokenURL, data, &tokens); err != nil { + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + return &tokens, nil +} + +// fetchJSON performs a GET request and unmarshals JSON response +func (c *OIDCClient) fetchJSON(url string, dest interface{}) error { + return c.httpClient.FetchJSON(url, dest) +} + +// postForm performs a POST request with form data +func (c *OIDCClient) postForm(url string, data url.Values, dest interface{}) error { + return c.httpClient.PostForm(url, data, dest) +} diff --git a/auth/types.go b/auth/types.go new file mode 100644 index 0000000..d98168d --- /dev/null +++ b/auth/types.go @@ -0,0 +1,30 @@ +package auth + +// OIDCConfig holds the OIDC provider configuration +type OIDCConfig struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + UserInfoURL string `json:"userinfo_endpoint"` + JWKSURL string `json:"jwks_uri"` + ScopesSupported []string `json:"scopes_supported"` +} + +// TokenResponse represents the response from token exchange +type TokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// UserInfo represents user information from the OIDC provider +type UserInfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Groups []string `json:"groups,omitempty"` +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7eb7bfe --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.flowmade.one/flowmade-one/iris + +go 1.23 diff --git a/host/server.go b/host/server.go new file mode 100644 index 0000000..ebf2781 --- /dev/null +++ b/host/server.go @@ -0,0 +1,150 @@ +package host + +import ( + "compress/gzip" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// Server provides a high-performance static file server with gzip compression +// optimized for serving WASM applications built with the Iris framework. +type Server struct { + publicDir string + indexFile string +} + +// New creates a new Server instance +func New(publicDir, indexFile string) *Server { + return &Server{ + publicDir: publicDir, + indexFile: indexFile, + } +} + +// ServeHTTP implements http.Handler interface +// Falls back to index file when: +// (1) Request path is not found +// (2) Request path is a directory +// Otherwise serves the requested file with gzip compression if supported. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p := filepath.Join(s.publicDir, filepath.Clean(r.URL.Path)) + + if info, err := os.Stat(p); err != nil { + s.serveFileWithCompression(w, r, filepath.Join(s.publicDir, s.indexFile)) + return + } else if info.IsDir() { + s.serveFileWithCompression(w, r, filepath.Join(s.publicDir, s.indexFile)) + return + } + + s.serveFileWithCompression(w, r, p) +} + +// serveFileWithCompression serves a file with gzip compression if supported by client +func (s *Server) serveFileWithCompression(w http.ResponseWriter, r *http.Request, filePath string) { + // Check if client accepts gzip + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + http.ServeFile(w, r, filePath) + return + } + + // Open and read file + file, err := os.Open(filePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer file.Close() + + // Get file info for headers + fileInfo, err := file.Stat() + if err != nil { + http.Error(w, "Error reading file", http.StatusInternalServerError) + return + } + + // Set content type based on file extension + contentType := getContentType(filepath.Ext(filePath)) + w.Header().Set("Content-Type", contentType) + + // Check if we should compress this file type + if shouldCompress(contentType) { + // Set gzip headers + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Vary", "Accept-Encoding") + + // Create gzip writer + gz := gzip.NewWriter(w) + defer gz.Close() + + // Copy file content through gzip + _, err = io.Copy(gz, file) + if err != nil { + http.Error(w, "Error compressing file", http.StatusInternalServerError) + return + } + } else { + // Serve without compression + http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file) + } +} + +// shouldCompress determines if the content should be compressed based on Content-Type +func shouldCompress(contentType string) bool { + compressibleTypes := []string{ + "text/html", + "text/css", + "text/javascript", + "application/javascript", + "application/json", + "application/wasm", + "text/plain", + "image/svg+xml", + } + + for _, t := range compressibleTypes { + if strings.Contains(contentType, t) { + return true + } + } + return false +} + +// getContentType returns the appropriate content type for a file extension +func getContentType(ext string) string { + switch ext { + case ".html": + return "text/html; charset=utf-8" + case ".css": + return "text/css" + case ".js": + return "application/javascript" + case ".wasm": + return "application/wasm" + case ".json": + return "application/json" + case ".ico": + return "image/x-icon" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".svg": + return "image/svg+xml" + case ".woff": + return "font/woff" + case ".woff2": + return "font/woff2" + case ".ttf": + return "font/ttf" + case ".eot": + return "application/vnd.ms-fontobject" + default: + return "application/octet-stream" + } +} \ No newline at end of file diff --git a/internal/element/element.go b/internal/element/element.go new file mode 100644 index 0000000..a6259fd --- /dev/null +++ b/internal/element/element.go @@ -0,0 +1,283 @@ +//build +js,wasm + +package element + +import ( + "fmt" + "syscall/js" +) + +type Element struct { + js.Value +} + +var doc = js.Global().Get("document") + +func Document() Element { + return Element{doc} +} + +func NewElement(tag string) Element { + doc := Document() + e := doc.Call("createElement", tag) + return Element{e} +} + +func NewTextNode(text string) Element { + doc := Document() + textNode := doc.Call("createTextNode", text) + return Element{textNode} +} + +func (e Element) On(event string, fn func()) Element { + e.Call("addEventListener", event, js.FuncOf(func(this js.Value, args []js.Value) interface{} { + fn() + return nil + })) + return e +} + +func (e Element) Margin(margin string) Element { + e.Get("style").Call("setProperty", "margin", margin) + return e +} + +func (e Element) Padding(padding string) Element { + e.Get("style").Call("setProperty", "padding", padding) + return e +} + +func (e Element) Height(height string) Element { + e.Get("style").Call("setProperty", "height", height) + return e +} + +func (e Element) MinHeight(height string) Element { + e.Get("style").Call("setProperty", "min-height", height) + return e +} + +func (e Element) Color(color string) Element { + e.Get("style").Call("setProperty", "color", color) + return e +} + +func (e Element) Width(width string) Element { + e.Get("style").Call("setProperty", "width", width) + return e +} + +func (e Element) BackgroundColor(color string) Element { + e.Get("style").Call("setProperty", "background-color", color) + return e +} + +func (e Element) Attr(name, value string) Element { + e.Call("setAttribute", name, value) + return e +} + +func (e Element) Text(text string) Element { + doc := Document() + textNode := doc.Call("createTextNode", text) + e.Call("appendChild", textNode) + return e +} + +func (e Element) Child(child Element) Element { + e.Call("appendChild", child.Value) + return e +} + +func Mount(root Element) { + doc := Document() + body := doc.Get("body") + + body.Call("appendChild", root.Value) +} + +func (e Element) Flex() Element { + e.Get("style").Call("setProperty", "display", "flex") + return e +} + +func (e Element) FlexDirection(direction string) Element { + e.Get("style").Call("setProperty", "flex-direction", direction) + return e +} + +func (e Element) JustifyContent(justify string) Element { + e.Get("style").Call("setProperty", "justify-content", justify) + return e +} + +func (e Element) JustifyItems(justify string) Element { + e.Get("style").Call("setProperty", "justify-items", justify) + return e +} + +func (e Element) AlignItems(align string) Element { + e.Get("style").Call("setProperty", "align-items", align) + return e +} + +func (e Element) FlexGrow(grow int) Element { + e.Get("style").Call("setProperty", "flex-grow", grow) + return e +} + +func (e Element) FlexWrap(wrap string) Element { + e.Get("style").Call("setProperty", "flex-wrap", wrap) + return e +} + +func (e Element) Grid() Element { + e.Get("style").Call("setProperty", "display", "grid") + return e +} + +func (e Element) GridAutoFlow(flow string) Element { + e.Get("style").Call("setProperty", "grid-auto-flow", flow) + return e +} + +func (e Element) GridTemplateColumns(columns string) Element { + e.Get("style").Call("setProperty", "grid-template-columns", columns) + return e +} + +func (e Element) GridTemplateRows(rows string) Element { + e.Get("style").Call("setProperty", "grid-template-rows", rows) + return e +} + +func (e Element) MaxWidth(width string) Element { + e.Get("style").Call("setProperty", "max-width", width) + return e +} + +// OnWithEvent adds an event listener that provides access to the event object +func (e Element) OnWithEvent(event string, fn func(js.Value)) Element { + e.Call("addEventListener", event, js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) > 0 { + fn(args[0]) + } + return nil + })) + return e +} + +// Positioning methods + +func (e Element) Position(pos string) Element { + e.Get("style").Call("setProperty", "position", pos) + return e +} + +func (e Element) Left(value string) Element { + e.Get("style").Call("setProperty", "left", value) + return e +} + +func (e Element) Top(value string) Element { + e.Get("style").Call("setProperty", "top", value) + return e +} + +func (e Element) Right(value string) Element { + e.Get("style").Call("setProperty", "right", value) + return e +} + +func (e Element) Bottom(value string) Element { + e.Get("style").Call("setProperty", "bottom", value) + return e +} + +func (e Element) ZIndex(value int) Element { + e.Get("style").Call("setProperty", "z-index", fmt.Sprintf("%d", value)) + return e +} + +// Transform methods + +func (e Element) Transform(value string) Element { + e.Get("style").Call("setProperty", "transform", value) + return e +} + +func (e Element) TransformOrigin(value string) Element { + e.Get("style").Call("setProperty", "transform-origin", value) + return e +} + +// Cursor and interaction + +func (e Element) Cursor(value string) Element { + e.Get("style").Call("setProperty", "cursor", value) + return e +} + +func (e Element) UserSelect(value string) Element { + e.Get("style").Call("setProperty", "user-select", value) + return e +} + +func (e Element) PointerEvents(value string) Element { + e.Get("style").Call("setProperty", "pointer-events", value) + return e +} + +// Overflow + +func (e Element) Overflow(value string) Element { + e.Get("style").Call("setProperty", "overflow", value) + return e +} + +// Box styling + +func (e Element) BoxShadow(value string) Element { + e.Get("style").Call("setProperty", "box-shadow", value) + return e +} + +func (e Element) BorderRadius(value string) Element { + e.Get("style").Call("setProperty", "border-radius", value) + return e +} + +func (e Element) Border(value string) Element { + e.Get("style").Call("setProperty", "border", value) + return e +} + +// DOM manipulation + +func (e Element) RemoveChild(child Element) Element { + e.Call("removeChild", child.Value) + return e +} + +func (e Element) ClearChildren() Element { + e.Set("innerHTML", "") + return e +} + +// SVG support + +func NewElementNS(namespace, tag string) Element { + doc := Document() + e := doc.Call("createElementNS", namespace, tag) + return Element{e} +} + +// AttrNS sets an attribute with namespace (for SVG) +func (e Element) AttrNS(namespace, name, value string) Element { + if namespace == "" { + e.Call("setAttribute", name, value) + } else { + e.Call("setAttributeNS", namespace, name, value) + } + return e +} diff --git a/navigation/components.go b/navigation/components.go new file mode 100644 index 0000000..0c596b3 --- /dev/null +++ b/navigation/components.go @@ -0,0 +1,90 @@ +package navigation + +import ( + "git.flowmade.one/flowmade-one/iris/reactive" + "git.flowmade.one/flowmade-one/iris/ui" +) + +var globalRouter *Router +var globalHistory *HistoryManager + +func SetGlobalRouter(router *Router) { + globalRouter = router + globalHistory = NewHistoryManager(router) + globalHistory.Start() +} + +func GetGlobalRouter() *Router { + return globalRouter +} + +// RouterView renders the current route's view +func RouterView() ui.View { + if globalRouter == nil { + return ui.TextFromString("Router not initialized").Color("#ff4444") + } + + // Create a view that updates when the route changes + view := ui.NewView() + + reactive.NewEffect(func() { + currentView := globalRouter.GetCurrentView() + // Clear previous content and add new view + // Note: This is a simplified approach - in a real implementation + // we'd need better DOM diffing + view = currentView + }) + + return view +} + +// Link creates a navigational link that updates the route +func Link(path string, content ui.View) ui.View { + if globalHistory == nil { + return content + } + + // Create a simple clickable element that navigates with proper styling for horizontal layout + button := ui.Button(func() { + globalHistory.PushState(path) + }, content) + + // Style the button to look like a link and work in horizontal layouts + return button.Padding("8px 12px").Background("transparent").Border("none") +} + +// Navigate programmatically navigates to a path +func Navigate(path string) { + if globalHistory != nil { + globalHistory.PushState(path) + } +} + +// Replace programmatically replaces current path +func Replace(path string) { + if globalHistory != nil { + globalHistory.ReplaceState(path) + } +} + +// Back navigates back in history +func Back() { + if globalHistory != nil { + globalHistory.Back() + } +} + +// Forward navigates forward in history +func Forward() { + if globalHistory != nil { + globalHistory.Forward() + } +} + +// GetCurrentPath returns the current route path +func GetCurrentPath() string { + if globalRouter != nil { + return globalRouter.GetCurrentPath() + } + return "" +} \ No newline at end of file diff --git a/navigation/core.go b/navigation/core.go new file mode 100644 index 0000000..43ea0e5 --- /dev/null +++ b/navigation/core.go @@ -0,0 +1,69 @@ +package navigation + +import ( + "strings" +) + +// Core routing logic separated from UI dependencies + +type RoutePattern struct { + parts []string + params []string +} + +func CompileRoutePattern(path string) RoutePattern { + var params []string + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 1 && parts[0] == "" { + parts = []string{} // Handle root path + } + + // Find parameters like :id, :name, etc. + for _, part := range parts { + if strings.HasPrefix(part, ":") { + paramName := part[1:] // Remove the ':' + // Simple validation: param name should start with letter + if len(paramName) > 0 && isLetterCore(paramName[0]) { + params = append(params, paramName) + } + } + } + + return RoutePattern{parts, params} +} + +func MatchRoute(routePattern RoutePattern, path string) map[string]string { + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} // Handle root path + } + + if len(pathParts) != len(routePattern.parts) { + return nil + } + + params := make(map[string]string) + paramIndex := 0 + + for i, routePart := range routePattern.parts { + if strings.HasPrefix(routePart, ":") { + // This is a parameter + if paramIndex < len(routePattern.params) { + params[routePattern.params[paramIndex]] = pathParts[i] + paramIndex++ + } + } else { + // Static part must match exactly + if routePart != pathParts[i] { + return nil + } + } + } + + return params +} + +// isLetterCore checks if a byte is a letter +func isLetterCore(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} \ No newline at end of file diff --git a/navigation/guards.go b/navigation/guards.go new file mode 100644 index 0000000..ec66b4b --- /dev/null +++ b/navigation/guards.go @@ -0,0 +1,91 @@ +package navigation + +import "strings" + +// Common route guards + +// AuthGuard checks if user is authenticated +func AuthGuard(isAuthenticated func() bool) RouteGuard { + return func(route *Route, params map[string]string) bool { + return isAuthenticated() + } +} + +// RoleGuard checks if user has required role +func RoleGuard(userRole func() string, requiredRole string) RouteGuard { + return func(route *Route, params map[string]string) bool { + return userRole() == requiredRole + } +} + +// PermissionGuard checks if user has specific permission +func PermissionGuard(hasPermission func(permission string) bool, permission string) RouteGuard { + return func(route *Route, params map[string]string) bool { + return hasPermission(permission) + } +} + +// ParamValidationGuard validates route parameters +func ParamValidationGuard(validator func(params map[string]string) bool) RouteGuard { + return func(route *Route, params map[string]string) bool { + return validator(params) + } +} + +// PathPrefixGuard checks if the current path starts with a specific prefix +func PathPrefixGuard(prefix string) RouteGuard { + return func(route *Route, params map[string]string) bool { + currentPath := GetCurrentPath() + return strings.HasPrefix(currentPath, prefix) + } +} + +// CombineGuards combines multiple guards with AND logic +func CombineGuards(guards ...RouteGuard) RouteGuard { + return func(route *Route, params map[string]string) bool { + for _, guard := range guards { + if !guard(route, params) { + return false + } + } + return true + } +} + +// AnyGuard combines multiple guards with OR logic +func AnyGuard(guards ...RouteGuard) RouteGuard { + return func(route *Route, params map[string]string) bool { + for _, guard := range guards { + if guard(route, params) { + return true + } + } + return false + } +} + +// Example usage patterns: + +// AdminOnlyGuard - combines authentication and role checks +func AdminOnlyGuard(isAuth func() bool, userRole func() string) RouteGuard { + return CombineGuards( + AuthGuard(isAuth), + RoleGuard(userRole, "admin"), + ) +} + +// NumericIdGuard - validates that :id parameter is numeric +func NumericIdGuard() RouteGuard { + return ParamValidationGuard(func(params map[string]string) bool { + if id, exists := params["id"]; exists { + // Simple numeric check + for _, char := range id { + if char < '0' || char > '9' { + return false + } + } + return len(id) > 0 + } + return true // No id param, so validation passes + }) +} \ No newline at end of file diff --git a/navigation/history.go b/navigation/history.go new file mode 100644 index 0000000..84e35c7 --- /dev/null +++ b/navigation/history.go @@ -0,0 +1,63 @@ +//build +js,wasm + +package navigation + +import ( + "syscall/js" +) + +type HistoryManager struct { + router *Router + window js.Value +} + +func NewHistoryManager(router *Router) *HistoryManager { + return &HistoryManager{ + router: router, + window: js.Global().Get("window"), + } +} + +func (h *HistoryManager) Start() { + // Handle initial navigation + currentPath := h.getCurrentPath() + h.router.Navigate(currentPath) + + // Listen for browser back/forward events + h.window.Call("addEventListener", "popstate", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + path := h.getCurrentPath() + h.router.Navigate(path) + return nil + })) +} + +func (h *HistoryManager) PushState(path string) { + h.window.Get("history").Call("pushState", nil, "", path) + h.router.Navigate(path) +} + +func (h *HistoryManager) ReplaceState(path string) { + h.window.Get("history").Call("replaceState", nil, "", path) + h.router.Navigate(path) +} + +func (h *HistoryManager) Back() { + h.window.Get("history").Call("back") +} + +func (h *HistoryManager) Forward() { + h.window.Get("history").Call("forward") +} + +func (h *HistoryManager) getCurrentPath() string { + location := h.window.Get("location") + pathname := location.Get("pathname").String() + search := location.Get("search").String() + + path := pathname + if search != "" { + path += search + } + + return path +} \ No newline at end of file diff --git a/navigation/router.go b/navigation/router.go new file mode 100644 index 0000000..ddaa7f6 --- /dev/null +++ b/navigation/router.go @@ -0,0 +1,157 @@ +package navigation + +import ( + "strings" + + "git.flowmade.one/flowmade-one/iris/reactive" + "git.flowmade.one/flowmade-one/iris/ui" +) + +type RouteGuard func(route *Route, params map[string]string) bool + +type Route struct { + Path string + Handler func(params map[string]string) ui.View + Guards []RouteGuard + parts []string + params []string +} + +type Router struct { + routes []Route + currentPath *reactive.Signal[string] + currentView *reactive.Signal[ui.View] + notFound func() ui.View +} + +func NewRouter(routes []Route) *Router { + currentPath := reactive.NewSignal("") + currentView := reactive.NewSignal(ui.NewView()) + + router := &Router{ + routes: make([]Route, len(routes)), + currentPath: ¤tPath, + currentView: ¤tView, + notFound: defaultNotFound, + } + + // Parse route patterns + for i, route := range routes { + parsedRoute := route + parsedRoute.parts, parsedRoute.params = parseRoute(route.Path) + router.routes[i] = parsedRoute + } + + return router +} + +func (r *Router) SetNotFoundHandler(handler func() ui.View) { + r.notFound = handler +} + +func (r *Router) Navigate(path string) { + r.currentPath.Set(path) + r.updateView(path) +} + +func (r *Router) GetCurrentPath() string { + return r.currentPath.Get() +} + +func (r *Router) GetCurrentView() ui.View { + return r.currentView.Get() +} + +func (r *Router) updateView(path string) { + for _, route := range r.routes { + if params := r.matchRoute(&route, path); params != nil { + // Check guards + if r.checkGuards(&route, params) { + view := route.Handler(params) + r.currentView.Set(view) + return + } + } + } + + // No route matched or guards failed + r.currentView.Set(r.notFound()) +} + +func (r *Router) matchRoute(route *Route, path string) map[string]string { + // Strip query parameters for route matching + if queryIndex := strings.Index(path, "?"); queryIndex != -1 { + path = path[:queryIndex] + } + + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} // Handle root path + } + + if len(pathParts) != len(route.parts) { + return nil + } + + params := make(map[string]string) + paramIndex := 0 + + for i, routePart := range route.parts { + if strings.HasPrefix(routePart, ":") { + // This is a parameter + if paramIndex < len(route.params) { + params[route.params[paramIndex]] = pathParts[i] + paramIndex++ + } + } else { + // Static part must match exactly + if routePart != pathParts[i] { + return nil + } + } + } + + return params +} + +func (r *Router) checkGuards(route *Route, params map[string]string) bool { + for _, guard := range route.Guards { + if !guard(route, params) { + return false + } + } + return true +} + +func parseRoute(path string) ([]string, []string) { + var params []string + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 1 && parts[0] == "" { + parts = []string{} // Handle root path + } + + // Find parameters like :id, :name, etc. + for _, part := range parts { + if strings.HasPrefix(part, ":") { + paramName := part[1:] // Remove the ':' + // Simple validation: param name should start with letter + if len(paramName) > 0 && isLetter(paramName[0]) { + params = append(params, paramName) + } + } + } + + return parts, params +} + +// isLetter checks if a byte is a letter +func isLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +func defaultNotFound() ui.View { + return ui.VerticalGroup( + ui.TextFromString("404 - Page Not Found").Color("#ff4444"), + ui.TextFromString("The requested page could not be found.").Color("#666"), + ) +} diff --git a/reactive/effect.go b/reactive/effect.go new file mode 100644 index 0000000..2431933 --- /dev/null +++ b/reactive/effect.go @@ -0,0 +1,18 @@ +package reactive + +type EffectId int + +func NewEffect(f func()) { + rt := GetRuntime() + effectId := EffectId(len(rt.effects)) + rt.effects = append(rt.effects, f) + + runEffect(rt, effectId) +} + +func NewEffectWithRuntime(rt *Runtime, f func()) { + effectId := EffectId(len(rt.effects)) + rt.effects = append(rt.effects, f) + + runEffect(rt, effectId) +} diff --git a/reactive/runtime.go b/reactive/runtime.go new file mode 100644 index 0000000..91eed3b --- /dev/null +++ b/reactive/runtime.go @@ -0,0 +1,39 @@ +package reactive + +import "sync" + +type Runtime struct { + signalValues []any + runningEffect *EffectId + signalSubscribers map[SignalId][]EffectId + effects []func() +} + +var lock = &sync.Mutex{} +var runtimeInstance *Runtime + +func GetRuntime() *Runtime { + if runtimeInstance == nil { + lock.Lock() + defer lock.Unlock() + if runtimeInstance == nil { + runtimeInstance = newRuntime() + } + } + return runtimeInstance +} + +func newRuntime() *Runtime { + return &Runtime{ + signalValues: []any{}, + signalSubscribers: map[SignalId][]EffectId{}, + effects: []func(){}, + } +} + +func runEffect(rt *Runtime, id EffectId) { + previous := rt.runningEffect + rt.runningEffect = &id + rt.effects[id]() + rt.runningEffect = previous +} diff --git a/reactive/signal.go b/reactive/signal.go new file mode 100644 index 0000000..d38c6fd --- /dev/null +++ b/reactive/signal.go @@ -0,0 +1,52 @@ +package reactive + +type SignalId int + +type Signal[T any] struct { + *Runtime + SignalId +} + +func NewSignal[T any](initial T) Signal[T] { + rt := GetRuntime() + id := SignalId(len(rt.signalValues)) + rt.signalValues = append(rt.signalValues, initial) + return Signal[T]{rt, id} +} + +func NewSignalWithRuntime[T any](rt *Runtime, initial T) Signal[T] { + id := SignalId(len(rt.signalValues)) + rt.signalValues = append(rt.signalValues, initial) + return Signal[T]{rt, id} +} + +func (s *Signal[T]) Get() T { + val := s.Runtime.signalValues[s.SignalId] + + if rt := s.Runtime; rt.runningEffect != nil { + // Check if this effect is already subscribed to this signal + subscribers := rt.signalSubscribers[s.SignalId] + alreadySubscribed := false + for _, effectId := range subscribers { + if effectId == *rt.runningEffect { + alreadySubscribed = true + break + } + } + if !alreadySubscribed { + rt.signalSubscribers[s.SignalId] = append(rt.signalSubscribers[s.SignalId], *rt.runningEffect) + } + } + + return val.(T) +} + +func (s *Signal[T]) Set(v T) { + s.Runtime.signalValues[s.SignalId] = v + + if subscribers, ok := s.Runtime.signalSubscribers[s.SignalId]; ok { + for _, effectId := range subscribers { + runEffect(s.Runtime, effectId) + } + } +} diff --git a/ui/app.go b/ui/app.go new file mode 100644 index 0000000..2c09efd --- /dev/null +++ b/ui/app.go @@ -0,0 +1,15 @@ +package ui + +import ( + _ "syscall/js" + + "git.flowmade.one/flowmade-one/iris/internal/element" +) + +func NewApp(view View) { + app := NewView() + app.e.Grid() + app.MinHeight("100vh") + app.Child(view) + element.Mount(app.e) +} diff --git a/ui/app_router.go b/ui/app_router.go new file mode 100644 index 0000000..f8b8399 --- /dev/null +++ b/ui/app_router.go @@ -0,0 +1,29 @@ +package ui + +import ( + "git.flowmade.one/flowmade-one/iris/internal/element" + "git.flowmade.one/flowmade-one/iris/reactive" +) + +// Router interface to avoid import cycle +type Router interface { + GetCurrentView() View +} + +// NewAppWithRouter creates a new app with navigation support +func NewAppWithRouter(router Router) { + app := NewView() + app.e.Grid() + app.MinHeight("100vh") + + // Create a reactive view that updates when routes change + reactive.NewEffect(func() { + currentView := router.GetCurrentView() + // Clear the app and add the current view + // Note: In a production implementation, we'd want better DOM diffing + app.e.Set("innerHTML", "") + app.Child(currentView) + }) + + element.Mount(app.e) +} \ No newline at end of file diff --git a/ui/button.go b/ui/button.go new file mode 100644 index 0000000..a961783 --- /dev/null +++ b/ui/button.go @@ -0,0 +1,9 @@ +package ui + +import "git.flowmade.one/flowmade-one/iris/internal/element" + +func Button(action func(), label View) View { + btn := View{element.NewElement("button").On("click", action)} + btn.Child(label) + return btn +} diff --git a/ui/canvas.go b/ui/canvas.go new file mode 100644 index 0000000..856d7ea --- /dev/null +++ b/ui/canvas.go @@ -0,0 +1,1124 @@ +//go:build js && wasm + +package ui + +import ( + "fmt" + "sort" + "syscall/js" + + "git.flowmade.one/flowmade-one/iris/internal/element" + "git.flowmade.one/flowmade-one/iris/reactive" +) + +// Note: Additional geometry functions are in canvas_geometry.go + +// Point represents a 2D coordinate +type Point struct { + X float64 + Y float64 +} + +// InteractionMode represents the current user interaction mode on the canvas +type InteractionMode int + +const ( + ModeIdle InteractionMode = iota + ModeDragging + ModePanning + ModeDrawingConnection + ModeResizing +) + +// ResizeHandle represents which resize handle is being dragged +type ResizeHandle int + +const ( + HandleNone ResizeHandle = iota + HandleSE // Southeast (bottom-right) + HandleE // East (right) + HandleS // South (bottom) +) + +// SelectionType indicates what kind of item is selected +type SelectionType int + +const ( + SelectionNone SelectionType = iota + SelectionItem + SelectionConnection +) + +// Selection represents the current selection state +type Selection struct { + Type SelectionType + ID string +} + +// ConnectionStyle defines the visual style for a connection +type ConnectionStyle struct { + Color string // Line color (default: "#333333") + StrokeWidth string // Line width (default: "2") + DashArray string // "" for solid, "8,4" for dashed, "2,2" for dotted +} + +// CanvasConnection represents a connection between two items +type CanvasConnection struct { + ID string + FromID string + ToID string + Label string // Optional label displayed on the connection + Style ConnectionStyle + Data any // App-specific data (e.g., connection type) +} + +// DrawingConnectionState represents a connection being drawn (temporary line) +type DrawingConnectionState struct { + FromID string + FromPoint Point + ToPoint Point + Style ConnectionStyle +} + +// CanvasItem represents an item that can be placed on a canvas +type CanvasItem struct { + ID string + Position Point + Size Point // Width, Height + ZIndex int // Lower values render first (background), higher values on top + Resizable bool // If true, show resize handles when selected + Content func(selected bool) View + Data any // App-specific data +} + +// CanvasConfig holds configuration options for the canvas +type CanvasConfig struct { + Width string // CSS width (e.g., "100%", "800px") + Height string // CSS height + Background string // Canvas background color + GridSize int // Snap-to-grid size (0 = disabled) + + // Item callbacks + OnItemSelect func(id string) + OnItemMove func(id string, pos Point, delta Point) + OnItemDrag func(id string, pos Point, delta Point) // Called during drag (before mouseup) + OnItemResize func(id string, size Point) // Called when an item is resized + OnCanvasClick func(pos Point) // Called when clicking empty canvas area + OnContainerClick func(containerID string, pos Point) // Called when clicking inside a container (not header) + + // Container detection - if set, items with ZIndex 0 are treated as containers + IsContainer func(id string) bool + + // Connection drawing callbacks + OnEdgeDragStart func(itemID string, edgePoint Point) // Called when drag starts from element edge + OnEdgeDragMove func(currentPoint Point) // Called during edge drag with current position + OnEdgeDragEnd func(targetItemID string) // Called when edge drag ends (targetItemID is "" if on empty space) + + // Connection callbacks + OnConnectionSelect func(id string) // Called when a connection is selected +} + +// CanvasState holds all state for the canvas +type CanvasState struct { + Items reactive.Signal[[]CanvasItem] + + // Unified selection state + Selection reactive.Signal[Selection] + + // Connection state + Connections reactive.Signal[[]CanvasConnection] + DrawingConnection reactive.Signal[*DrawingConnectionState] + + // Drag position overrides for connection rendering (reactive for live updates) + DragPositions reactive.Signal[map[string]Point] + + // Viewport state (not reactive - only used for rendering) + viewportOffset Point + zoom float64 + + // Internal interaction state + mode InteractionMode + dragItemID string + dragStart Point + itemStartPos Point + panStart Point + draggedElement js.Value // Reference to DOM element being dragged + + // Connection drawing source (used when mode == ModeDrawingConnection) + drawingSourceID string + + // Resize state (used when mode == ModeResizing) + resizeHandle ResizeHandle + resizeItemID string + itemStartSize Point + resizedElement js.Value // Reference to DOM element being resized + + // DOM element references for direct manipulation during drag + itemElements map[string]js.Value + itemStartPositions map[string]Point // Store start positions for drag delta calculation +} + +// NewCanvasState creates a new canvas state +func NewCanvasState() *CanvasState { + return &CanvasState{ + Items: reactive.NewSignal([]CanvasItem{}), + Selection: reactive.NewSignal(Selection{Type: SelectionNone, ID: ""}), + Connections: reactive.NewSignal([]CanvasConnection{}), + DrawingConnection: reactive.NewSignal[*DrawingConnectionState](nil), + DragPositions: reactive.NewSignal(map[string]Point{}), + zoom: 1.0, + itemElements: make(map[string]js.Value), + itemStartPositions: make(map[string]Point), + } +} + +// AddItem adds an item to the canvas +func (s *CanvasState) AddItem(item CanvasItem) { + items := s.Items.Get() + items = append(items, item) + s.Items.Set(items) +} + +// RemoveItem removes an item by ID +func (s *CanvasState) RemoveItem(id string) { + items := s.Items.Get() + for i, item := range items { + if item.ID == id { + items = append(items[:i], items[i+1:]...) + break + } + } + s.Items.Set(items) + + // Clear selection if removed item was selected + sel := s.Selection.Get() + if sel.Type == SelectionItem && sel.ID == id { + s.Selection.Set(Selection{Type: SelectionNone, ID: ""}) + } +} + +// UpdateItem updates an item by ID using an update function +func (s *CanvasState) UpdateItem(id string, update func(*CanvasItem)) { + items := s.Items.Get() + for i := range items { + if items[i].ID == id { + update(&items[i]) + break + } + } + s.Items.Set(items) +} + +// MoveItemDOMByDelta moves an item's DOM element directly during drag (without triggering re-render) +func (s *CanvasState) MoveItemDOMByDelta(id string, delta Point) { + elem, ok := s.itemElements[id] + if !ok || elem.IsUndefined() || elem.IsNull() { + return + } + + startPos, ok := s.itemStartPositions[id] + if !ok { + return + } + + newX := startPos.X + delta.X + newY := startPos.Y + delta.Y + + elem.Get("style").Set("left", fmt.Sprintf("%fpx", newX)) + elem.Get("style").Set("top", fmt.Sprintf("%fpx", newY)) +} + +// GetItemStartPosition returns the start position of an item (from before drag started) +func (s *CanvasState) GetItemStartPosition(id string) Point { + if pos, ok := s.itemStartPositions[id]; ok { + return pos + } + return Point{} +} + +// UpdateItemPosition updates an item's position and its DOM element during drag +func (s *CanvasState) UpdateItemPosition(id string, pos Point) { + // Update DOM element directly + if elem, ok := s.itemElements[id]; ok && !elem.IsUndefined() && !elem.IsNull() { + elem.Get("style").Set("left", fmt.Sprintf("%fpx", pos.X)) + elem.Get("style").Set("top", fmt.Sprintf("%fpx", pos.Y)) + } + + // Update in Items signal (for connection rendering) + items := s.Items.Get() + for i := range items { + if items[i].ID == id { + items[i].Position = pos + break + } + } + s.Items.Set(items) +} + +// SelectItem selects an item by ID +func (s *CanvasState) SelectItem(id string) { + if id == "" { + s.Selection.Set(Selection{Type: SelectionNone, ID: ""}) + } else { + s.Selection.Set(Selection{Type: SelectionItem, ID: id}) + } +} + +// ClearSelection deselects all items and connections +func (s *CanvasState) ClearSelection() { + s.Selection.Set(Selection{Type: SelectionNone, ID: ""}) +} + +// GetSelection returns the current selection state +func (s *CanvasState) GetSelection() Selection { + return s.Selection.Get() +} + +// GetSelectedItem returns the currently selected item, or nil +func (s *CanvasState) GetSelectedItem() *CanvasItem { + sel := s.Selection.Get() + if sel.Type != SelectionItem || sel.ID == "" { + return nil + } + return s.GetItem(sel.ID) +} + +// GetItem returns an item by ID, or nil if not found +func (s *CanvasState) GetItem(id string) *CanvasItem { + items := s.Items.Get() + for i := range items { + if items[i].ID == id { + return &items[i] + } + } + return nil +} + +// AddConnection adds a connection to the canvas +func (s *CanvasState) AddConnection(conn CanvasConnection) { + conns := s.Connections.Get() + conns = append(conns, conn) + s.Connections.Set(conns) +} + +// RemoveConnection removes a connection by ID +func (s *CanvasState) RemoveConnection(id string) { + conns := s.Connections.Get() + for i, conn := range conns { + if conn.ID == id { + conns = append(conns[:i], conns[i+1:]...) + break + } + } + s.Connections.Set(conns) + + // Clear selection if removed connection was selected + sel := s.Selection.Get() + if sel.Type == SelectionConnection && sel.ID == id { + s.Selection.Set(Selection{Type: SelectionNone, ID: ""}) + } +} + +// UpdateConnection updates a connection by ID using an update function +func (s *CanvasState) UpdateConnection(id string, update func(*CanvasConnection)) { + conns := s.Connections.Get() + for i := range conns { + if conns[i].ID == id { + update(&conns[i]) + break + } + } + s.Connections.Set(conns) +} + +// SelectConnection selects a connection by ID +func (s *CanvasState) SelectConnection(id string) { + if id == "" { + s.Selection.Set(Selection{Type: SelectionNone, ID: ""}) + } else { + s.Selection.Set(Selection{Type: SelectionConnection, ID: id}) + } +} + +// GetConnection returns a connection by ID, or nil if not found +func (s *CanvasState) GetConnection(id string) *CanvasConnection { + conns := s.Connections.Get() + for i := range conns { + if conns[i].ID == id { + return &conns[i] + } + } + return nil +} + +// GetSelectedConnection returns the currently selected connection, or nil +func (s *CanvasState) GetSelectedConnection() *CanvasConnection { + sel := s.Selection.Get() + if sel.Type != SelectionConnection || sel.ID == "" { + return nil + } + return s.GetConnection(sel.ID) +} + +// ScreenToCanvas converts screen coordinates to canvas coordinates +func (s *CanvasState) ScreenToCanvas(screen Point) Point { + return Point{ + X: (screen.X - s.viewportOffset.X) / s.zoom, + Y: (screen.Y - s.viewportOffset.Y) / s.zoom, + } +} + +// CanvasToScreen converts canvas coordinates to screen coordinates +func (s *CanvasState) CanvasToScreen(canvas Point) Point { + return Point{ + X: canvas.X*s.zoom + s.viewportOffset.X, + Y: canvas.Y*s.zoom + s.viewportOffset.Y, + } +} + +// Canvas creates a canvas component +func Canvas(state *CanvasState, config CanvasConfig) View { + // Create viewport container + viewport := element.NewElement("div") + viewport.Position("relative") + viewport.Overflow("hidden") + viewport.Width(config.Width) + viewport.Height(config.Height) + viewport.BackgroundColor(config.Background) + viewport.UserSelect("none") + + // Create content container (holds all items, transforms for pan/zoom) + content := element.NewElement("div") + content.Position("absolute") + content.Left("0") + content.Top("0") + content.Width("100%") + content.Height("100%") + + // Create SVG layer for connections (rendered first, so items appear on top) + connectionsSVG := SVG(). + Width("100%"). + Height("100%"). + Position("absolute"). + Left("0"). + Top("0"). + PointerEvents("none") // Let clicks pass through to items + + // Add arrowhead marker definitions + defs := SVGDefs() + defs = defs.ChildSVG(createArrowMarker("default", "#333333")) + defs = defs.ChildSVG(createArrowMarker("selected", "#E74C3C")) + defs = defs.ChildSVG(createArrowMarker("drawing", "#95A5A6")) + connectionsSVG = connectionsSVG.ChildSVG(defs) + + // Track content element for re-rendering + var contentRef = content + var svgRef = connectionsSVG + + // Helper to render all items + renderItems := func() { + contentRef.ClearChildren() + + // Clear element references (will be repopulated) + state.itemElements = make(map[string]js.Value) + state.itemStartPositions = make(map[string]Point) + + // Add SVG layer first (if valid) + if svgRef.e.Value.Truthy() { + contentRef.Child(svgRef.e) + } + + items := state.Items.Get() + sel := state.Selection.Get() + selectedID := "" + if sel.Type == SelectionItem { + selectedID = sel.ID + } + + // Sort items by ZIndex (lower values render first/behind) + sortedItems := make([]CanvasItem, len(items)) + copy(sortedItems, items) + sort.Slice(sortedItems, func(i, j int) bool { + return sortedItems[i].ZIndex < sortedItems[j].ZIndex + }) + + for _, item := range sortedItems { + if item.ID == "" { + continue // Skip invalid items + } + itemDiv := renderCanvasItem(state, item, item.ID == selectedID, config) + contentRef.Child(itemDiv) + + // Store element reference and position for drag operations + state.itemElements[item.ID] = itemDiv.Value + state.itemStartPositions[item.ID] = item.Position + } + } + + // Helper to render connections + renderConnections := func() { + // Clear existing connections (but keep defs) + newSVG := SVG(). + Width("100%"). + Height("100%"). + Position("absolute"). + Left("0"). + Top("0"). + PointerEvents("none") + + // Re-add defs + newDefs := SVGDefs() + newDefs = newDefs.ChildSVG(createArrowMarker("default", "#333333")) + newDefs = newDefs.ChildSVG(createArrowMarker("selected", "#E74C3C")) + newDefs = newDefs.ChildSVG(createArrowMarker("drawing", "#95A5A6")) + newSVG = newSVG.ChildSVG(newDefs) + + connections := state.Connections.Get() + items := state.Items.Get() + sel := state.Selection.Get() + selectedConnID := "" + if sel.Type == SelectionConnection { + selectedConnID = sel.ID + } + drawing := state.DrawingConnection.Get() + + // Build item lookup map + itemMap := make(map[string]CanvasItem) + for _, item := range items { + itemMap[item.ID] = item + } + + // Render existing connections + for _, conn := range connections { + fromItem, fromOK := itemMap[conn.FromID] + toItem, toOK := itemMap[conn.ToID] + if !fromOK || !toOK { + continue + } + + isSelected := conn.ID == selectedConnID + group := renderConnection(state, conn, fromItem, toItem, isSelected, config) + newSVG = newSVG.ChildSVG(group) + } + + // Render drawing connection (temporary line during drag) + if drawing != nil { + line := SVGLine(drawing.FromPoint.X, drawing.FromPoint.Y, drawing.ToPoint.X, drawing.ToPoint.Y). + Stroke("#95A5A6"). + StrokeWidth("2"). + StrokeDasharray("4,4"). + MarkerEnd("url(#arrow-drawing)") + newSVG = newSVG.ChildSVG(line) + } + + svgRef = newSVG + } + + // Main reactive effect to re-render everything (for state changes) + // Order matters: connections first (creates SVG), then items (adds SVG and item divs) + reactive.NewEffect(func() { + // Subscribe to all relevant signals + _ = state.Items.Get() + _ = state.Selection.Get() + _ = state.Connections.Get() + _ = state.DrawingConnection.Get() + + // Render connections first (updates svgRef) + renderConnections() + // Then render items (clears content and adds svgRef + items) + renderItems() + }) + + + // Mouse down on viewport (for panning or clicking empty area) + viewport.OnWithEvent("mousedown", func(e js.Value) { + target := e.Get("target") + + // Check if clicked on viewport background (not an item) + // This includes the viewport itself, the content container, or the SVG element + isBackground := target.Equal(viewport.Value) || target.Equal(contentRef.Value) + + // Also check if clicked on the SVG element (connections layer) + // The SVG is regenerated on each render, so we check by tag name + tagName := target.Get("tagName").String() + if tagName == "svg" || tagName == "SVG" { + isBackground = true + } + + if isBackground { + e.Call("preventDefault") + + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + // Get viewport bounds + rect := viewport.Call("getBoundingClientRect") + x := clientX - rect.Get("left").Float() + y := clientY - rect.Get("top").Float() + + // Clear selection when clicking background + state.ClearSelection() + if config.OnItemSelect != nil { + config.OnItemSelect("") + } + + // Callback for canvas click + if config.OnCanvasClick != nil { + canvasPos := state.ScreenToCanvas(Point{X: x, Y: y}) + config.OnCanvasClick(canvasPos) + } + + // Start panning + state.mode = ModePanning + state.panStart = Point{X: clientX, Y: clientY} + } + }) + + // Global mouse move handler + doc := element.Document() + doc.OnWithEvent("mousemove", func(e js.Value) { + if state.mode == ModeIdle { + return + } + + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + if state.mode == ModeDragging && !state.draggedElement.IsUndefined() && !state.draggedElement.IsNull() { + // Calculate new position + deltaX := (clientX - state.dragStart.X) / state.zoom + deltaY := (clientY - state.dragStart.Y) / state.zoom + + newPos := Point{ + X: state.itemStartPos.X + deltaX, + Y: state.itemStartPos.Y + deltaY, + } + + // Snap to grid + newPos = snapToGrid(newPos, config.GridSize) + + // Calculate snapped delta + snappedDelta := Point{ + X: newPos.X - state.itemStartPos.X, + Y: newPos.Y - state.itemStartPos.Y, + } + + // Update DOM directly during drag (don't trigger reactive re-render) + state.draggedElement.Get("style").Set("left", fmt.Sprintf("%fpx", newPos.X)) + state.draggedElement.Get("style").Set("top", fmt.Sprintf("%fpx", newPos.Y)) + + // Notify about drag in progress (for moving children etc.) + if config.OnItemDrag != nil { + config.OnItemDrag(state.dragItemID, newPos, snappedDelta) + } + } + + if state.mode == ModePanning { + deltaX := clientX - state.panStart.X + deltaY := clientY - state.panStart.Y + + state.viewportOffset.X += deltaX + state.viewportOffset.Y += deltaY + state.panStart = Point{X: clientX, Y: clientY} + + // Update transform + transform := fmt.Sprintf("translate(%fpx, %fpx) scale(%f)", + state.viewportOffset.X, state.viewportOffset.Y, state.zoom) + content.Transform(transform) + } + + if state.mode == ModeDrawingConnection && config.OnEdgeDragMove != nil { + // Get viewport bounds + rect := viewport.Call("getBoundingClientRect") + x := clientX - rect.Get("left").Float() + y := clientY - rect.Get("top").Float() + canvasPos := state.ScreenToCanvas(Point{X: x, Y: y}) + config.OnEdgeDragMove(canvasPos) + } + + if state.mode == ModeResizing && !state.resizedElement.IsUndefined() && !state.resizedElement.IsNull() { + // Calculate delta + deltaX := (clientX - state.dragStart.X) / state.zoom + deltaY := (clientY - state.dragStart.Y) / state.zoom + + // Calculate new size based on which handle is being dragged + newWidth := state.itemStartSize.X + newHeight := state.itemStartSize.Y + + switch state.resizeHandle { + case HandleSE: + newWidth = state.itemStartSize.X + deltaX + newHeight = state.itemStartSize.Y + deltaY + case HandleE: + newWidth = state.itemStartSize.X + deltaX + case HandleS: + newHeight = state.itemStartSize.Y + deltaY + } + + // Apply minimum size constraints + minWidth := DefaultCanvasDefaults.MinResizeWidth + minHeight := DefaultCanvasDefaults.MinResizeHeight + if newWidth < minWidth { + newWidth = minWidth + } + if newHeight < minHeight { + newHeight = minHeight + } + + // Update DOM directly during resize (update the wrapper element) + state.resizedElement.Get("style").Set("width", fmt.Sprintf("%fpx", newWidth)) + state.resizedElement.Get("style").Set("height", fmt.Sprintf("%fpx", newHeight)) + } + }) + + // Global mouse up handler + doc.OnWithEvent("mouseup", func(e js.Value) { + if state.mode == ModeDragging { + // Calculate final position from current drag state + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + deltaX := (clientX - state.dragStart.X) / state.zoom + deltaY := (clientY - state.dragStart.Y) / state.zoom + + finalPos := Point{ + X: state.itemStartPos.X + deltaX, + Y: state.itemStartPos.Y + deltaY, + } + finalPos = snapToGrid(finalPos, config.GridSize) + + // Check if this was a click (minimal movement) or a drag + dragThreshold := DefaultCanvasDefaults.DragThreshold + wasDrag := abs(deltaX) > dragThreshold || abs(deltaY) > dragThreshold + + // Clear drag state BEFORE any callbacks (which may trigger re-render) + draggedItemID := state.dragItemID + state.mode = ModeIdle + state.dragItemID = "" + state.draggedElement = js.Undefined() + + if wasDrag { + // Update the state with final position (triggers re-render) + state.UpdateItem(draggedItemID, func(item *CanvasItem) { + item.Position = finalPos + }) + + if config.OnItemMove != nil { + delta := Point{X: deltaX, Y: deltaY} + config.OnItemMove(draggedItemID, finalPos, delta) + } + } else { + // Only select on click (not drag) to avoid cascading effects + if config.OnItemSelect != nil { + config.OnItemSelect(draggedItemID) + } else { + state.SelectItem(draggedItemID) + } + } + + // Early return since we handled ModeDragging + return + } + + if state.mode == ModeDrawingConnection && config.OnEdgeDragEnd != nil { + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + // Find item under cursor + targetID := findItemUnderPoint(state, viewport, clientX, clientY) + + // Don't connect to self + if targetID == state.drawingSourceID { + LogCanvasWarning(WarnSelfConnection, "cannot connect item %s to itself", targetID) + targetID = "" + } + + config.OnEdgeDragEnd(targetID) + } + + if state.mode == ModeResizing { + // Calculate final size + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + deltaX := (clientX - state.dragStart.X) / state.zoom + deltaY := (clientY - state.dragStart.Y) / state.zoom + + newWidth := state.itemStartSize.X + newHeight := state.itemStartSize.Y + + switch state.resizeHandle { + case HandleSE: + newWidth = state.itemStartSize.X + deltaX + newHeight = state.itemStartSize.Y + deltaY + case HandleE: + newWidth = state.itemStartSize.X + deltaX + case HandleS: + newHeight = state.itemStartSize.Y + deltaY + } + + // Apply minimum size constraints + minWidth := DefaultCanvasDefaults.MinResizeWidth + minHeight := DefaultCanvasDefaults.MinResizeHeight + if newWidth < minWidth { + newWidth = minWidth + } + if newHeight < minHeight { + newHeight = minHeight + } + + finalSize := Point{X: newWidth, Y: newHeight} + + // Clear resize state BEFORE callbacks + resizedItemID := state.resizeItemID + state.mode = ModeIdle + state.resizeItemID = "" + state.resizeHandle = HandleNone + state.resizedElement = js.Undefined() + + // Update state with final size (triggers re-render) + state.UpdateItem(resizedItemID, func(item *CanvasItem) { + item.Size = finalSize + }) + + if config.OnItemResize != nil { + config.OnItemResize(resizedItemID, finalSize) + } + + return + } + + // Reset all interaction state + state.mode = ModeIdle + state.dragItemID = "" + state.drawingSourceID = "" + state.draggedElement = js.Undefined() + state.resizeItemID = "" + state.resizeHandle = HandleNone + state.resizedElement = js.Undefined() + }) + + viewport.Child(content) + return View{viewport} +} + +// renderCanvasItem creates a DOM element for a canvas item +func renderCanvasItem(state *CanvasState, item CanvasItem, selected bool, config CanvasConfig) element.Element { + wrapper := element.NewElement("div") + wrapper.Attr("data-item-id", item.ID) // Add ID for DOM queries + wrapper.Position("absolute") + wrapper.Left(fmt.Sprintf("%fpx", item.Position.X)) + wrapper.Top(fmt.Sprintf("%fpx", item.Position.Y)) + wrapper.Cursor("grab") + + // Set explicit size on wrapper if item has size defined + // This is needed for resizable items where content uses 100% width/height + if item.Size.X > 0 && item.Size.Y > 0 { + wrapper.Width(fmt.Sprintf("%fpx", item.Size.X)) + wrapper.Height(fmt.Sprintf("%fpx", item.Size.Y)) + } + + // Render content + if item.Content != nil { + contentView := item.Content(selected) + if contentView.e.Value.Truthy() { + wrapper.Child(contentView.e) + } + } + + // Add resize handles if item is resizable and selected + if item.Resizable && selected { + cornerSize := fmt.Sprintf("%.0fpx", DefaultCanvasDefaults.CornerHandleSize) + + // Southeast handle (bottom-right corner) - main resize handle + seHandle := element.NewElement("div") + seHandle.Position("absolute") + seHandle.Right("0") + seHandle.Bottom("0") + seHandle.Width(cornerSize) + seHandle.Height(cornerSize) + seHandle.BackgroundColor("#607D8B") + seHandle.Cursor("se-resize") + seHandle.Set("style", seHandle.Get("style").String()+"; border-radius: 2px; z-index: 10;") + + // Add mousedown handler for resize + seHandle.OnWithEvent("mousedown", func(e js.Value) { + e.Call("stopPropagation") + e.Call("preventDefault") + + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + // Get current item state + currentItem := state.GetItem(item.ID) + if currentItem == nil { + LogCanvasWarning(WarnItemNotFound, "item %s not found during SE resize", item.ID) + return + } + + // Start resize mode + state.mode = ModeResizing + state.resizeHandle = HandleSE + state.resizeItemID = item.ID + state.dragStart = Point{X: clientX, Y: clientY} + state.itemStartSize = currentItem.Size + state.resizedElement = e.Get("currentTarget").Get("parentElement") + }) + + wrapper.Child(seHandle) + + edgeWidth := fmt.Sprintf("%.0fpx", DefaultCanvasDefaults.EdgeHandleWidth) + edgeLength := fmt.Sprintf("%.0fpx", DefaultCanvasDefaults.EdgeHandleLength) + + // East handle (right edge) + eHandle := element.NewElement("div") + eHandle.Position("absolute") + eHandle.Right("0") + eHandle.Top("50%") + eHandle.Width(edgeWidth) + eHandle.Height(edgeLength) + eHandle.BackgroundColor("#607D8B") + eHandle.Cursor("e-resize") + eHandle.Set("style", eHandle.Get("style").String()+"; border-radius: 2px; transform: translateY(-50%); z-index: 10;") + + eHandle.OnWithEvent("mousedown", func(e js.Value) { + e.Call("stopPropagation") + e.Call("preventDefault") + + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + currentItem := state.GetItem(item.ID) + if currentItem == nil { + LogCanvasWarning(WarnItemNotFound, "item %s not found during E resize", item.ID) + return + } + + state.mode = ModeResizing + state.resizeHandle = HandleE + state.resizeItemID = item.ID + state.dragStart = Point{X: clientX, Y: clientY} + state.itemStartSize = currentItem.Size + state.resizedElement = e.Get("currentTarget").Get("parentElement") + }) + + wrapper.Child(eHandle) + + // South handle (bottom edge) + sHandle := element.NewElement("div") + sHandle.Position("absolute") + sHandle.Bottom("0") + sHandle.Left("50%") + sHandle.Width(edgeLength) // Swapped: length is horizontal for bottom edge + sHandle.Height(edgeWidth) // Swapped: width is vertical for bottom edge + sHandle.BackgroundColor("#607D8B") + sHandle.Cursor("s-resize") + sHandle.Set("style", sHandle.Get("style").String()+"; border-radius: 2px; transform: translateX(-50%); z-index: 10;") + + sHandle.OnWithEvent("mousedown", func(e js.Value) { + e.Call("stopPropagation") + e.Call("preventDefault") + + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + currentItem := state.GetItem(item.ID) + if currentItem == nil { + LogCanvasWarning(WarnItemNotFound, "item %s not found during S resize", item.ID) + return + } + + state.mode = ModeResizing + state.resizeHandle = HandleS + state.resizeItemID = item.ID + state.dragStart = Point{X: clientX, Y: clientY} + state.itemStartSize = currentItem.Size + state.resizedElement = e.Get("currentTarget").Get("parentElement") + }) + + wrapper.Child(sHandle) + } + + // Capture item data for closure - avoid referencing item directly + itemID := item.ID + itemPosition := item.Position + itemSize := item.Size + + // Check if this item is a container + isContainer := config.IsContainer != nil && config.IsContainer(itemID) + + // Mouse down on item (for dragging, selection, or connection drawing) + wrapper.OnWithEvent("mousedown", func(e js.Value) { + e.Call("stopPropagation") + e.Call("preventDefault") + + // Get click position relative to element BEFORE any state changes + // Use currentTarget instead of wrapper reference (which may be stale after re-render) + target := e.Get("currentTarget") + rect := target.Call("getBoundingClientRect") + clickX := e.Get("clientX").Float() - rect.Get("left").Float() + clickY := e.Get("clientY").Float() - rect.Get("top").Float() + elemWidth := rect.Get("width").Float() + elemHeight := rect.Get("height").Float() + clientX := e.Get("clientX").Float() + clientY := e.Get("clientY").Float() + + // For containers: check if click is in the header area + // If clicking inside the container body (not header), pass through to OnContainerClick + containerHeaderHeight := DefaultCanvasDefaults.ContainerHeaderHeight + if isContainer && clickY > containerHeaderHeight { + // Click inside container body - treat as canvas click to create element inside + if config.OnContainerClick != nil { + // Get canvas coordinates for the click position + viewportRect := target.Get("parentElement").Call("getBoundingClientRect") + x := clientX - viewportRect.Get("left").Float() + y := clientY - viewportRect.Get("top").Float() + canvasPos := state.ScreenToCanvas(Point{X: x, Y: y}) + config.OnContainerClick(itemID, canvasPos) + return + } + } + + // Check if click is in edge zone (for connection drawing) + if isNearEdge(clickX, clickY, elemWidth, elemHeight) && config.OnEdgeDragStart != nil { + // Edge drag - start connection drawing + // Use captured item data for edge point calculation + tempItem := CanvasItem{ID: itemID, Position: itemPosition, Size: itemSize} + edgePoint := calculateEdgePoint(tempItem, clickX, clickY, elemWidth, elemHeight) + state.mode = ModeDrawingConnection + state.drawingSourceID = itemID + config.OnEdgeDragStart(itemID, edgePoint) + } else { + // Center drag - element dragging + // Get current position from state (not captured value which may be stale) + currentItem := state.GetItem(itemID) + if currentItem == nil { + LogCanvasWarning(WarnItemNotFound, "item %s not found during drag start", itemID) + return + } + currentPos := currentItem.Position + + // Set up drag state - do NOT trigger selection during drag + // Selection will cause re-render which invalidates our DOM reference + state.mode = ModeDragging + state.dragItemID = itemID + state.dragStart = Point{X: clientX, Y: clientY} + state.itemStartPos = currentPos + state.draggedElement = target // Store reference to DOM element for direct manipulation + } + }) + + return wrapper +} + +// createArrowMarker creates an SVG marker element for arrowheads +func createArrowMarker(id, color string) SVGElement { + path := SVGPath("M0,0 L0,6 L9,3 z").Fill(color) + + marker := SVGMarker("arrow-" + id). + MarkerWidth("10"). + MarkerHeight("10"). + RefX("9"). + RefY("3"). + Orient("auto"). + MarkerUnits("strokeWidth"). + ChildSVG(path) + + return marker +} + +// renderConnection creates an SVG group for a connection with click handling +func renderConnection(state *CanvasState, conn CanvasConnection, fromItem, toItem CanvasItem, isSelected bool, config CanvasConfig) SVGElement { + // Calculate center points + fromCenter := Point{ + X: fromItem.Position.X + fromItem.Size.X/2, + Y: fromItem.Position.Y + fromItem.Size.Y/2, + } + toCenter := Point{ + X: toItem.Position.X + toItem.Size.X/2, + Y: toItem.Position.Y + toItem.Size.Y/2, + } + + // Calculate edge points + fromEdge := calculateConnectionEdgePoint(fromCenter, toCenter, fromItem.Size) + toEdge := calculateConnectionEdgePoint(toCenter, fromCenter, toItem.Size) + + group := SVGGroup() + + // Invisible wider line for easier clicking (hit area) + hitWidth := fmt.Sprintf("%.0f", DefaultCanvasDefaults.ConnectionHitWidth) + hitArea := SVGLine(fromEdge.X, fromEdge.Y, toEdge.X, toEdge.Y). + Stroke("transparent"). + StrokeWidth(hitWidth). + PointerEvents("stroke"). + Cursor("pointer") + + // Add click handler for hit area + connID := conn.ID + hitArea = hitArea.OnClick(func() { + state.SelectConnection(connID) + if config.OnConnectionSelect != nil { + config.OnConnectionSelect(connID) + } + }) + + // Visible line with selection styling + var visibleLine SVGElement + if isSelected { + visibleLine = SVGLine(fromEdge.X, fromEdge.Y, toEdge.X, toEdge.Y). + Stroke("#E74C3C"). + StrokeWidth("3"). + MarkerEnd("url(#arrow-selected)") + } else { + color := conn.Style.Color + if color == "" { + color = "#333333" + } + strokeWidth := conn.Style.StrokeWidth + if strokeWidth == "" { + strokeWidth = "2" + } + visibleLine = SVGLine(fromEdge.X, fromEdge.Y, toEdge.X, toEdge.Y). + Stroke(color). + StrokeWidth(strokeWidth). + MarkerEnd("url(#arrow-default)") + + if conn.Style.DashArray != "" { + visibleLine = visibleLine.StrokeDasharray(conn.Style.DashArray) + } + } + visibleLine = visibleLine.PointerEvents("none") // Let hit area handle clicks + + group = group.ChildSVG(hitArea).ChildSVG(visibleLine) + + // Add label if present + if conn.Label != "" { + // Calculate midpoint of the line + midX := (fromEdge.X + toEdge.X) / 2 + midY := (fromEdge.Y + toEdge.Y) / 2 + + // Add a small background rect for readability + labelWidth := DefaultCanvasDefaults.LabelBackgroundWidth + labelHeight := DefaultCanvasDefaults.LabelBackgroundHeight + labelBg := SVGRect(midX-labelWidth/2, midY-labelHeight/2, labelWidth, labelHeight). + Fill("#FFFFFF"). + Stroke("#CCCCCC"). + StrokeWidth("1"). + Attr("rx", "3"). + Attr("ry", "3"). + PointerEvents("none") + + // Add the label text + labelText := SVGText(conn.Label, midX, midY). + FontSize("12px"). + FontFamily("sans-serif"). + TextAnchor("middle"). + DominantBaseline("middle"). + Fill("#333333"). + PointerEvents("none") + + group = group.ChildSVG(labelBg).ChildSVG(labelText) + } + + return group +} diff --git a/ui/canvas_config.go b/ui/canvas_config.go new file mode 100644 index 0000000..80d9e4f --- /dev/null +++ b/ui/canvas_config.go @@ -0,0 +1,51 @@ +//go:build js && wasm + +package ui + +// CanvasDefaults contains default values for canvas rendering and interaction +type CanvasDefaults struct { + // Interaction thresholds + EdgeThreshold float64 // Distance from edge to trigger connection drawing (default: 15) + DragThreshold float64 // Minimum movement to count as drag vs click (default: 2) + + // Resize constraints + MinResizeWidth float64 // Minimum width when resizing (default: 100) + MinResizeHeight float64 // Minimum height when resizing (default: 100) + + // Resize handle sizes + CornerHandleSize float64 // Size of corner resize handles (default: 12) + EdgeHandleWidth float64 // Width of edge resize handles (default: 8) + EdgeHandleLength float64 // Length of edge resize handles (default: 24) + + // Connection rendering + ConnectionHitWidth float64 // Width of invisible hit area for connections (default: 12) + LabelBackgroundWidth float64 // Width of label background rect (default: 60) + LabelBackgroundHeight float64 // Height of label background rect (default: 20) + + // Container layout + ContainerHeaderHeight float64 // Height of container header bar for click detection (default: 32) +} + +// DefaultCanvasDefaults returns the default configuration values +var DefaultCanvasDefaults = CanvasDefaults{ + // Interaction thresholds + EdgeThreshold: 15.0, + DragThreshold: 2.0, + + // Resize constraints + MinResizeWidth: 100.0, + MinResizeHeight: 100.0, + + // Resize handle sizes + CornerHandleSize: 12.0, + EdgeHandleWidth: 8.0, + EdgeHandleLength: 24.0, + + // Connection rendering + ConnectionHitWidth: 12.0, + LabelBackgroundWidth: 60.0, + LabelBackgroundHeight: 20.0, + + // Container layout + ContainerHeaderHeight: 32.0, +} diff --git a/ui/canvas_errors.go b/ui/canvas_errors.go new file mode 100644 index 0000000..81efb75 --- /dev/null +++ b/ui/canvas_errors.go @@ -0,0 +1,36 @@ +//go:build js && wasm + +package ui + +import "log" + +// CanvasWarning represents a type of canvas warning +type CanvasWarning int + +const ( + WarnItemNotFound CanvasWarning = iota + WarnConnectionNotFound + WarnInvalidOperation + WarnSelfConnection + WarnMissingCallback +) + +// warningNames maps warning types to human-readable names +var warningNames = map[CanvasWarning]string{ + WarnItemNotFound: "ItemNotFound", + WarnConnectionNotFound: "ConnectionNotFound", + WarnInvalidOperation: "InvalidOperation", + WarnSelfConnection: "SelfConnection", + WarnMissingCallback: "MissingCallback", +} + +// LogCanvasWarning logs a warning for recoverable canvas issues. +// These are situations where the operation cannot complete but the +// application can continue safely. +func LogCanvasWarning(warn CanvasWarning, format string, args ...any) { + name := warningNames[warn] + if name == "" { + name = "Unknown" + } + log.Printf("[Canvas %s] "+format, append([]any{name}, args...)...) +} diff --git a/ui/canvas_geometry.go b/ui/canvas_geometry.go new file mode 100644 index 0000000..674c9b7 --- /dev/null +++ b/ui/canvas_geometry.go @@ -0,0 +1,144 @@ +//go:build js && wasm + +package ui + +import ( + "sort" + + "git.flowmade.one/flowmade-one/iris/internal/element" +) + +// snapToGrid snaps a position to the grid if enabled +func snapToGrid(pos Point, gridSize int) Point { + if gridSize <= 0 { + return pos + } + return Point{ + X: float64(int(pos.X/float64(gridSize)+0.5) * gridSize), + Y: float64(int(pos.Y/float64(gridSize)+0.5) * gridSize), + } +} + +// findItemUnderPoint finds which canvas item is under the given screen coordinates +// Returns the item with highest ZIndex that contains the point +func findItemUnderPoint(state *CanvasState, viewport element.Element, clientX, clientY float64) string { + // Get viewport bounds + rect := viewport.Call("getBoundingClientRect") + viewportLeft := rect.Get("left").Float() + viewportTop := rect.Get("top").Float() + + // Convert to canvas coordinates + x := clientX - viewportLeft + y := clientY - viewportTop + canvasPos := state.ScreenToCanvas(Point{X: x, Y: y}) + + // Sort items by ZIndex descending (higher values first = top items first) + items := state.Items.Get() + sortedItems := make([]CanvasItem, len(items)) + copy(sortedItems, items) + sort.Slice(sortedItems, func(i, j int) bool { + return sortedItems[i].ZIndex > sortedItems[j].ZIndex + }) + + // Check items in z-order (top to bottom) + for _, item := range sortedItems { + if canvasPos.X >= item.Position.X && canvasPos.X <= item.Position.X+item.Size.X && + canvasPos.Y >= item.Position.Y && canvasPos.Y <= item.Position.Y+item.Size.Y { + return item.ID + } + } + return "" +} + +// isNearEdge checks if a click position is near the edge of an element +func isNearEdge(clickX, clickY, elemWidth, elemHeight float64) bool { + edgeThreshold := DefaultCanvasDefaults.EdgeThreshold + return clickX < edgeThreshold || + clickX > elemWidth-edgeThreshold || + clickY < edgeThreshold || + clickY > elemHeight-edgeThreshold +} + +// calculateEdgePoint calculates the edge point for a connection based on click position +func calculateEdgePoint(item CanvasItem, clickX, clickY, elemWidth, elemHeight float64) Point { + // Calculate center of element + centerX := item.Position.X + item.Size.X/2 + centerY := item.Position.Y + item.Size.Y/2 + + // Determine which edge is closest + distLeft := clickX + distRight := elemWidth - clickX + distTop := clickY + distBottom := elemHeight - clickY + + minDist := distLeft + edgeX := item.Position.X + edgeY := centerY + + if distRight < minDist { + minDist = distRight + edgeX = item.Position.X + item.Size.X + edgeY = centerY + } + if distTop < minDist { + minDist = distTop + edgeX = centerX + edgeY = item.Position.Y + } + if distBottom < minDist { + edgeX = centerX + edgeY = item.Position.Y + item.Size.Y + } + + return Point{X: edgeX, Y: edgeY} +} + +// calculateConnectionEdgePoint calculates where a line from center to target +// should intersect with the element's bounding box +func calculateConnectionEdgePoint(center, target, size Point) Point { + dx := target.X - center.X + dy := target.Y - center.Y + + // Handle edge case of overlapping centers + if dx == 0 && dy == 0 { + return center + } + + // Calculate intersection with bounding box + halfW := size.X / 2 + halfH := size.Y / 2 + + // Check which edge we intersect + if dx == 0 { + // Vertical line + if dy > 0 { + return Point{X: center.X, Y: center.Y + halfH} + } + return Point{X: center.X, Y: center.Y - halfH} + } + + slope := dy / dx + + // Check horizontal edges + if abs(slope) <= halfH/halfW { + // Intersects left or right edge + if dx > 0 { + return Point{X: center.X + halfW, Y: center.Y + slope*halfW} + } + return Point{X: center.X - halfW, Y: center.Y - slope*halfW} + } + + // Intersects top or bottom edge + if dy > 0 { + return Point{X: center.X + halfH/slope, Y: center.Y + halfH} + } + return Point{X: center.X - halfH/slope, Y: center.Y - halfH} +} + +// abs returns the absolute value of x +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} diff --git a/ui/font.go b/ui/font.go new file mode 100644 index 0000000..3315e52 --- /dev/null +++ b/ui/font.go @@ -0,0 +1,42 @@ +package ui + +type Font struct { + family string + size string + weight string + lineheight string +} + +func NewFont() Font { + return Font{ + family: "sans-serif", + size: "16px", + weight: "400", + lineheight: "20px", + } +} + +func (f Font) Family(family string) Font { + f.family = family + return f +} + +func (f Font) Size(size string) Font { + f.size = size + return f +} + +func (f Font) Weight(weight string) Font { + f.weight = weight + return f +} + +func (f Font) String() string { + return f.weight + " " + f.size + " " + f.family + "" +} + +// todo we can't apply this straight via the css, we need to apply it to the lineheight of the parent +func (f Font) LineHeight(lineheight string) Font { + f.lineheight = lineheight + return f +} diff --git a/ui/image.go b/ui/image.go new file mode 100644 index 0000000..7f7cd63 --- /dev/null +++ b/ui/image.go @@ -0,0 +1,7 @@ +package ui + +import "git.flowmade.one/flowmade-one/iris/internal/element" + +func Image(src string) View { + return View{element.NewElement("img").Attr("src", src)} +} diff --git a/ui/input.go b/ui/input.go new file mode 100644 index 0000000..0410301 --- /dev/null +++ b/ui/input.go @@ -0,0 +1,208 @@ +package ui + +import ( + "strconv" + + "git.flowmade.one/flowmade-one/iris/internal/element" + "git.flowmade.one/flowmade-one/iris/reactive" +) + +// TextInput creates a reactive text input field +func TextInput(value *reactive.Signal[string], placeholder string) View { + input := element.NewElement("input") + input.Attr("type", "text") + input.Attr("placeholder", placeholder) + + // Set initial value + input.Set("value", value.Get()) + + // Update input when signal changes + reactive.NewEffect(func() { + currentValue := value.Get() + if input.Get("value").String() != currentValue { + input.Set("value", currentValue) + } + }) + + // Update signal when user types + input.On("input", func() { + newValue := input.Get("value").String() + if value.Get() != newValue { + value.Set(newValue) + } + }) + + return View{input} +} + +// TextArea creates a multi-line text input +func TextArea(value *reactive.Signal[string], placeholder string, rows int) View { + textarea := element.NewElement("textarea") + textarea.Attr("placeholder", placeholder) + textarea.Attr("rows", string(rune(rows))) + + // Set initial value + textarea.Set("value", value.Get()) + + // Update textarea when signal changes + reactive.NewEffect(func() { + currentValue := value.Get() + if textarea.Get("value").String() != currentValue { + textarea.Set("value", currentValue) + } + }) + + // Update signal when user types + textarea.On("input", func() { + newValue := textarea.Get("value").String() + if value.Get() != newValue { + value.Set(newValue) + } + }) + + return View{textarea} +} + +// Checkbox creates a reactive checkbox input +func Checkbox(checked *reactive.Signal[bool], label string) View { + container := element.NewElement("label") + container.Get("style").Call("setProperty", "display", "flex") + container.Get("style").Call("setProperty", "align-items", "center") + container.Get("style").Call("setProperty", "gap", "8px") + container.Get("style").Call("setProperty", "cursor", "pointer") + + checkbox := element.NewElement("input") + checkbox.Attr("type", "checkbox") + + // Set initial state + if checked.Get() { + checkbox.Set("checked", true) + } + + // Update checkbox when signal changes + reactive.NewEffect(func() { + isChecked := checked.Get() + checkbox.Set("checked", isChecked) + }) + + // Update signal when user clicks + checkbox.On("change", func() { + newValue := checkbox.Get("checked").Bool() + checked.Set(newValue) + }) + + // Add label text + labelText := element.NewTextNode(label) + + container.Child(checkbox) + container.Child(labelText) + + return View{container} +} + +// Select creates a dropdown selection component +func Select(selected *reactive.Signal[string], options []string, placeholder string) View { + selectElem := element.NewElement("select") + + // Add placeholder option if provided + if placeholder != "" { + placeholderOption := element.NewElement("option") + placeholderOption.Set("value", "") + placeholderOption.Set("disabled", true) + placeholderOption.Set("selected", true) + placeholderOption.Set("textContent", placeholder) + selectElem.Child(placeholderOption) + } + + // Add options + for _, option := range options { + optionElem := element.NewElement("option") + optionElem.Set("value", option) + optionElem.Set("textContent", option) + selectElem.Child(optionElem) + } + + // Set initial value + selectElem.Set("value", selected.Get()) + + // Update select when signal changes + reactive.NewEffect(func() { + currentValue := selected.Get() + selectElem.Set("value", currentValue) + }) + + // Update signal when user selects + selectElem.On("change", func() { + newValue := selectElem.Get("value").String() + selected.Set(newValue) + }) + + return View{selectElem} +} + +// Slider creates a range input for numeric values +func Slider(value *reactive.Signal[int], min, max int) View { + slider := element.NewElement("input") + slider.Attr("type", "range") + slider.Attr("min", strconv.Itoa(min)) + slider.Attr("max", strconv.Itoa(max)) + + // Set initial value + slider.Set("value", strconv.Itoa(value.Get())) + + // Update slider when signal changes + reactive.NewEffect(func() { + currentValue := value.Get() + slider.Set("value", strconv.Itoa(currentValue)) + }) + + // Update signal when user drags + slider.On("input", func() { + newValueStr := slider.Get("value").String() + if newValue, err := strconv.Atoi(newValueStr); err == nil { + value.Set(newValue) + } + }) + + return View{slider} +} + +// NumberInput creates a reactive numeric input field for float64 values +func NumberInput(value *reactive.Signal[float64], placeholder string) View { + input := element.NewElement("input") + input.Attr("type", "number") + input.Attr("step", "0.01") + input.Attr("min", "0") + input.Attr("placeholder", placeholder) + + // Set initial value + if value.Get() != 0 { + input.Set("value", strconv.FormatFloat(value.Get(), 'f', -1, 64)) + } + + // Update input when signal changes + reactive.NewEffect(func() { + currentValue := value.Get() + var displayValue string + if currentValue == 0 { + displayValue = "" + } else { + displayValue = strconv.FormatFloat(currentValue, 'f', -1, 64) + } + if input.Get("value").String() != displayValue { + input.Set("value", displayValue) + } + }) + + // Update signal when user types + input.On("input", func() { + newValueStr := input.Get("value").String() + if newValueStr == "" { + value.Set(0.0) + } else if newValue, err := strconv.ParseFloat(newValueStr, 64); err == nil { + value.Set(newValue) + } + }) + + return View{input} +} \ No newline at end of file diff --git a/ui/layout.go b/ui/layout.go new file mode 100644 index 0000000..699563c --- /dev/null +++ b/ui/layout.go @@ -0,0 +1,90 @@ +package ui + +import "strings" + +// VerticalGroup is a view that arranges its children vertically +// It uses CSS Grid with a single column +func VerticalGroup(children ...View) View { + v := NewView() + v.e.Grid() + v.e.GridTemplateColumns("1fr") // Force single column + v.e.GridAutoFlow("row") + v.e.Width("100%") + for _, child := range children { + v.Child(child) + } + return v +} + +// HorizontalGroup is a view that arranges its children horizontally +// It uses flexbox to do so +func HorizontalGroup(children ...View) View { + v := NewView() + v.e.Grid() + v.e.GridTemplateColumns("repeat(auto-fit, minmax(250px, 1fr))") + v.e.Width("100%") + for _, child := range children { + v.Child(child) + } + return v +} + +// Spacer is a view that takes up all available space +// It uses flexbox to do so +func Spacer() View { + v := NewView() + v.e.Get("style").Call("setProperty", "flex", "1") + return v +} + +// OverlayGroup creates a container where children are stacked on top of each other +func OverlayGroup(children ...View) View { + v := NewView() + v.e.Get("style").Call("setProperty", "position", "relative") + v.e.Get("style").Call("setProperty", "display", "block") + + // Add children with absolute positioning (except first one) + for i, child := range children { + if i == 0 { + // First child acts as the base layer + v.Child(child) + } else { + // Subsequent children are absolutely positioned as overlays + // They need pointer-events: none so clicks pass through to layers below + child.e.Get("style").Call("setProperty", "position", "absolute") + child.e.Get("style").Call("setProperty", "top", "0") + child.e.Get("style").Call("setProperty", "left", "0") + child.e.Get("style").Call("setProperty", "width", "100%") + child.e.Get("style").Call("setProperty", "height", "100%") + child.e.Get("style").Call("setProperty", "pointer-events", "none") + v.Child(child) + } + } + + return v +} + +// Divider creates a visual separator line +func Divider() View { + v := NewView() + v.e.Get("style").Call("setProperty", "border-top", "1px solid #ccc") + v.e.Get("style").Call("setProperty", "margin", "8px 0") + v.e.Get("style").Call("setProperty", "width", "100%") + v.e.Get("style").Call("setProperty", "height", "1px") + return v +} + +// FlexLayout creates a horizontal layout with explicit column sizing +// Example: FlexLayout([]string{"48px", "1fr", "240px"}, left, center, right) +// Supports fixed widths (e.g., "48px"), flexible ("1fr"), and auto-sized ("auto") +func FlexLayout(columns []string, children ...View) View { + v := NewView() + v.e.Grid() + v.e.Get("style").Call("setProperty", "grid-template-columns", strings.Join(columns, " ")) + v.e.Width("100%") + v.e.Height("100%") + for _, child := range children { + v.Child(child) + } + return v +} diff --git a/ui/modifiers.go b/ui/modifiers.go new file mode 100644 index 0000000..0b4bcdd --- /dev/null +++ b/ui/modifiers.go @@ -0,0 +1,172 @@ +package ui + +func (v View) SpaceEvenly() View { + v.e.Get("style").Call("setProperty", "justify-content", "space-evenly") + return v +} + +func (v View) SpaceBetween() View { + v.e.Get("style").Call("setProperty", "justify-content", "space-between") + return v +} + +func (v View) AlignCenter() View { + v.e.Get("style").Call("setProperty", "align-items", "center") + return v +} + +func (v View) AlignRight() View { + v.e.Get("style").Call("setProperty", "align-items", "flex-end") + return v +} + +func (v View) AlignLeft() View { + v.e.Get("style").Call("setProperty", "align-items", "flex-start") + return v +} + +func (v View) JustifyContent(justify string) View { + v.e.Get("style").Call("setProperty", "justify-content", justify) + return v +} + +func (v View) Margin(margin string) View { + v.e.Get("style").Call("setProperty", "margin", margin) + return v +} + +func (v View) Padding(padding string) View { + v.e.Get("style").Call("setProperty", "padding", padding) + return v +} + +func (v View) Font(font Font) View { + v.e.Get("style").Call("setProperty", "font", font.String()) + v.e.Get("style").Call("setProperty", "line-height", font.lineheight) + return v +} + +func (v View) Color(color string) View { + v.e.Get("style").Call("setProperty", "color", color) + return v +} + +func (v View) Gap(gap string) View { + v.e.Get("style").Call("setProperty", "gap", gap) + return v +} + +func (v View) Border(border string) View { + v.e.Get("style").Call("setProperty", "border", border) + return v +} + +func (v View) BorderTop(border string) View { + v.e.Get("style").Call("setProperty", "border-top", border) + return v +} + +func (v View) BorderBottom(border string) View { + v.e.Get("style").Call("setProperty", "border-bottom", border) + return v +} + +func (v View) BorderLeft(border string) View { + v.e.Get("style").Call("setProperty", "border-left", border) + return v +} + +func (v View) BorderRight(border string) View { + v.e.Get("style").Call("setProperty", "border-right", border) + return v +} + +func (v View) TextAlign(align string) View { + v.e.Get("style").Call("setProperty", "text-align", align) + return v +} + +func (v View) MaxWidth(width string) View { + v.e.MaxWidth(width) + return v +} + +func (v View) Width(width string) View { + v.e.Width(width) + return v +} + +func (v View) Height(height string) View { + v.e.Height(height) + return v +} + +func (v View) Background(color string) View { + v.e.BackgroundColor(color) + return v +} + +func (v View) Foreground(color string) View { + v.e.Color(color) + return v +} + +func (v View) MinHeight(height string) View { + v.e.MinHeight(height) + return v +} + +func (v View) BoxShadow(shadow string) View { + v.e.BoxShadow(shadow) + return v +} + +func (v View) BorderRadius(radius string) View { + v.e.BorderRadius(radius) + return v +} + +func (v View) Cursor(cursor string) View { + v.e.Cursor(cursor) + return v +} + +func (v View) Position(pos string) View { + v.e.Position(pos) + return v +} + +func (v View) Left(value string) View { + v.e.Left(value) + return v +} + +func (v View) Top(value string) View { + v.e.Top(value) + return v +} + +func (v View) Overflow(value string) View { + v.e.Overflow(value) + return v +} + +func (v View) Display(value string) View { + v.e.Get("style").Call("setProperty", "display", value) + return v +} + +func (v View) GridTemplateColumns(value string) View { + v.e.Get("style").Call("setProperty", "grid-template-columns", value) + return v +} + +func (v View) AlignItems(value string) View { + v.e.Get("style").Call("setProperty", "align-items", value) + return v +} + +func (v View) PointerEvents(value string) View { + v.e.PointerEvents(value) + return v +} diff --git a/ui/svg.go b/ui/svg.go new file mode 100644 index 0000000..09950ef --- /dev/null +++ b/ui/svg.go @@ -0,0 +1,264 @@ +//go:build js && wasm + +package ui + +import ( + "fmt" + "syscall/js" + + "git.flowmade.one/flowmade-one/iris/internal/element" +) + +const svgNS = "http://www.w3.org/2000/svg" + +// SVG creates an SVG container element +func SVG() SVGElement { + e := element.NewElementNS(svgNS, "svg") + return SVGElement{e} +} + +// SVGElement wraps an SVG element +type SVGElement struct { + e element.Element +} + +// Attr sets an attribute on the SVG element +func (s SVGElement) Attr(name, value string) SVGElement { + s.e.Attr(name, value) + return s +} + +// Width sets the width attribute +func (s SVGElement) Width(value string) SVGElement { + s.e.Attr("width", value) + return s +} + +// Height sets the height attribute +func (s SVGElement) Height(value string) SVGElement { + s.e.Attr("height", value) + return s +} + +// Position sets CSS position +func (s SVGElement) Position(pos string) SVGElement { + s.e.Position(pos) + return s +} + +// Left sets CSS left +func (s SVGElement) Left(value string) SVGElement { + s.e.Left(value) + return s +} + +// Top sets CSS top +func (s SVGElement) Top(value string) SVGElement { + s.e.Top(value) + return s +} + +// PointerEvents sets CSS pointer-events +func (s SVGElement) PointerEvents(value string) SVGElement { + s.e.PointerEvents(value) + return s +} + +// Child adds a child SVG element +func (s SVGElement) ChildSVG(child SVGElement) SVGElement { + s.e.Child(child.e) + return s +} + +// ClearChildren removes all children +func (s SVGElement) ClearChildren() SVGElement { + s.e.ClearChildren() + return s +} + +// ToView converts the SVG element to a View (for embedding in layouts) +func (s SVGElement) ToView() View { + return View{s.e} +} + +// SVGDefs creates a defs element +func SVGDefs() SVGElement { + return SVGElement{element.NewElementNS(svgNS, "defs")} +} + +// SVGMarker creates a marker element for arrowheads +func SVGMarker(id string) SVGElement { + e := element.NewElementNS(svgNS, "marker") + e.Attr("id", id) + return SVGElement{e} +} + +// MarkerWidth sets marker width +func (s SVGElement) MarkerWidth(value string) SVGElement { + s.e.Attr("markerWidth", value) + return s +} + +// MarkerHeight sets marker height +func (s SVGElement) MarkerHeight(value string) SVGElement { + s.e.Attr("markerHeight", value) + return s +} + +// RefX sets refX +func (s SVGElement) RefX(value string) SVGElement { + s.e.Attr("refX", value) + return s +} + +// RefY sets refY +func (s SVGElement) RefY(value string) SVGElement { + s.e.Attr("refY", value) + return s +} + +// Orient sets orient +func (s SVGElement) Orient(value string) SVGElement { + s.e.Attr("orient", value) + return s +} + +// MarkerUnits sets markerUnits +func (s SVGElement) MarkerUnits(value string) SVGElement { + s.e.Attr("markerUnits", value) + return s +} + +// SVGPath creates a path element +func SVGPath(d string) SVGElement { + e := element.NewElementNS(svgNS, "path") + e.Attr("d", d) + return SVGElement{e} +} + +// Fill sets fill color +func (s SVGElement) Fill(color string) SVGElement { + s.e.Attr("fill", color) + return s +} + +// Stroke sets stroke color +func (s SVGElement) Stroke(color string) SVGElement { + s.e.Attr("stroke", color) + return s +} + +// StrokeWidth sets stroke width +func (s SVGElement) StrokeWidth(value string) SVGElement { + s.e.Attr("stroke-width", value) + return s +} + +// StrokeDasharray sets stroke dash pattern +func (s SVGElement) StrokeDasharray(value string) SVGElement { + s.e.Attr("stroke-dasharray", value) + return s +} + +// MarkerEnd sets the end marker +func (s SVGElement) MarkerEnd(value string) SVGElement { + s.e.Attr("marker-end", value) + return s +} + +// SVGLine creates a line element +func SVGLine(x1, y1, x2, y2 float64) SVGElement { + e := element.NewElementNS(svgNS, "line") + e.Attr("x1", fmt.Sprintf("%f", x1)) + e.Attr("y1", fmt.Sprintf("%f", y1)) + e.Attr("x2", fmt.Sprintf("%f", x2)) + e.Attr("y2", fmt.Sprintf("%f", y2)) + return SVGElement{e} +} + +// SVGRect creates a rect element +func SVGRect(x, y, width, height float64) SVGElement { + e := element.NewElementNS(svgNS, "rect") + e.Attr("x", fmt.Sprintf("%f", x)) + e.Attr("y", fmt.Sprintf("%f", y)) + e.Attr("width", fmt.Sprintf("%f", width)) + e.Attr("height", fmt.Sprintf("%f", height)) + return SVGElement{e} +} + +// SVGCircle creates a circle element +func SVGCircle(cx, cy, r float64) SVGElement { + e := element.NewElementNS(svgNS, "circle") + e.Attr("cx", fmt.Sprintf("%f", cx)) + e.Attr("cy", fmt.Sprintf("%f", cy)) + e.Attr("r", fmt.Sprintf("%f", r)) + return SVGElement{e} +} + +// SVGText creates a text element +func SVGText(text string, x, y float64) SVGElement { + e := element.NewElementNS(svgNS, "text") + e.Attr("x", fmt.Sprintf("%f", x)) + e.Attr("y", fmt.Sprintf("%f", y)) + e.Text(text) + return SVGElement{e} +} + +// SVGGroup creates a g (group) element +func SVGGroup() SVGElement { + return SVGElement{element.NewElementNS(svgNS, "g")} +} + +// OnClick adds a click handler to the SVG element +func (s SVGElement) OnClick(fn func()) SVGElement { + s.e.On("click", fn) + return s +} + +// OnClickWithEvent adds a click handler with event access +func (s SVGElement) OnClickWithEvent(fn func(js.Value)) SVGElement { + s.e.OnWithEvent("click", fn) + return s +} + +// DataAttr sets a data attribute (data-{name}="{value}") +func (s SVGElement) DataAttr(name, value string) SVGElement { + s.e.Attr("data-"+name, value) + return s +} + +// ID sets the id attribute +func (s SVGElement) ID(id string) SVGElement { + s.e.Attr("id", id) + return s +} + +// Cursor sets the CSS cursor property +func (s SVGElement) Cursor(value string) SVGElement { + s.e.Cursor(value) + return s +} + +// FontSize sets the font-size attribute +func (s SVGElement) FontSize(value string) SVGElement { + s.e.Attr("font-size", value) + return s +} + +// FontFamily sets the font-family attribute +func (s SVGElement) FontFamily(value string) SVGElement { + s.e.Attr("font-family", value) + return s +} + +// TextAnchor sets the text-anchor attribute (start, middle, end) +func (s SVGElement) TextAnchor(value string) SVGElement { + s.e.Attr("text-anchor", value) + return s +} + +// DominantBaseline sets the dominant-baseline attribute +func (s SVGElement) DominantBaseline(value string) SVGElement { + s.e.Attr("dominant-baseline", value) + return s +} diff --git a/ui/text.go b/ui/text.go new file mode 100644 index 0000000..e5a5590 --- /dev/null +++ b/ui/text.go @@ -0,0 +1,23 @@ +package ui + +import ( + "git.flowmade.one/flowmade-one/iris/internal/element" + "git.flowmade.one/flowmade-one/iris/reactive" +) + +func TextFromString(text string) View { + v := View{element.NewElement("p")} + v.e.Set("textContent", text) + return v +} + +func TextFromFunction(fn func() string) View { + textNode := element.NewElement("p") + + reactive.NewEffect(func() { + value := fn() + textNode.Set("textContent", value) + }) + + return View{textNode} +} diff --git a/ui/utils.go b/ui/utils.go new file mode 100644 index 0000000..ba3a6a6 --- /dev/null +++ b/ui/utils.go @@ -0,0 +1,10 @@ +//go:build js && wasm + +package ui + +import "fmt" + +// FormatPx formats a float as a CSS pixel value +func FormatPx(v float64) string { + return fmt.Sprintf("%.0fpx", v) +} diff --git a/ui/view.go b/ui/view.go new file mode 100644 index 0000000..df6079f --- /dev/null +++ b/ui/view.go @@ -0,0 +1,29 @@ +package ui + +import "git.flowmade.one/flowmade-one/iris/internal/element" + +type View struct { + e element.Element +} + +func (v View) Child(child View) View { + v.e.Child(child.e) + return v +} + +func NewView() View { + e := element.NewElement("div") + e.JustifyItems("center") + e.AlignItems("center") + return View{e} +} + +// NewViewFromElement creates a View from an element (for advanced use cases) +func NewViewFromElement(e element.Element) View { + return View{e} +} + +// Element returns the underlying element (for advanced use cases) +func (v View) Element() element.Element { + return v.e +} diff --git a/vision.md b/vision.md new file mode 100644 index 0000000..b9b09c8 --- /dev/null +++ b/vision.md @@ -0,0 +1,37 @@ +# Iris Vision + +WASM reactive UI framework for Go - build browser applications without JavaScript. + +## Organization Context + +This repo is part of Flowmade. See [organization manifesto](../architecture/manifesto.md) for who we are and what we believe. + +## What This Is + +Iris is an open-source UI framework that enables Go developers to build reactive browser applications compiled to WebAssembly. It provides: + +- **Signals-based reactivity** - Automatic DOM updates when state changes +- **Component library** - Button, Text, Input, View, Canvas, SVG +- **Client-side routing** - Router with guards and history management +- **OIDC authentication** - Browser-based auth flows for WASM apps +- **Static file server** - Host component for serving WASM applications + +## Who This Serves + +- **Go developers** who want to build web UIs without learning JavaScript +- **Teams** building internal tools and dashboards +- **Projects** that benefit from shared Go code between server and client + +## Goals + +1. **Simple reactivity** - Signals that just work, no complex state management +2. **Familiar patterns** - Go idioms, not React/Vue patterns forced into Go +3. **WASM-first** - Designed for WebAssembly, not ported from elsewhere +4. **Minimal dependencies** - Only syscall/js for browser interop + +## Non-Goals + +- Server-side rendering (SSR) +- Virtual DOM diffing +- CSS-in-Go styling system +- Component marketplace