Compare commits
96 Commits
v0.0.1
...
07cd9e84eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cd9e84eb
|
||
|
|
a31a49ad50
|
||
|
|
850d641a25
|
||
|
|
ee5ca81757
|
||
|
|
22a11832b8
|
||
|
|
b5ce5723fa
|
||
|
|
102130cc77
|
||
|
|
1668a2e6d5
|
||
|
|
b0af83004e | ||
|
|
51de3ced13
|
||
| 8d403ba803 | |||
|
|
6bb301153a
|
||
|
|
02eee5f9bf
|
||
|
|
4f48a519a9
|
||
|
|
97199ac20f
|
||
|
|
f36f0cb230
|
||
|
|
ed3d48e07f
|
||
|
|
c610d6c991
|
||
|
8865b9b032
|
|||
|
235ee5d979
|
|||
|
23a48572f3
|
|||
|
e89a46e146
|
|||
|
cefda41fcf
|
|||
|
21d0f0da5a
|
|||
|
46202451ba
|
|||
|
0f4239d179
|
|||
|
d9c9bb5234
|
|||
|
18802fdc10
|
|||
|
b1cbd23542
|
|||
|
33f10da396
|
|||
|
af5b3b23ba
|
|||
|
64d75b9686
|
|||
|
|
2e6466ceca
|
||
|
|
20d8e2abed
|
||
|
|
715e1d095b
|
||
|
|
07e2826f16
|
||
|
|
e0ad97b003
|
||
|
|
93df4a19ff
|
||
|
|
d661a4e4a9
|
||
|
|
c7f808ce2d
|
||
|
|
72d6cd6ea2
|
||
|
|
615f2d3c48
|
||
|
|
2fadb6802d
|
||
|
|
9271d3a7e4
|
||
|
|
13c83efdb9
|
||
|
|
e44b73bebf
|
||
|
979e9fd922
|
|||
|
544500e7fe
|
|||
|
aaebbc4bc0
|
|||
|
|
894ab70b79 | ||
|
f8a2a95bc1
|
|||
|
c9dd143916
|
|||
|
898dd49aee
|
|||
|
9fb69d760f
|
|||
|
bafbcca2b8
|
|||
|
8ad9e5535c
|
|||
|
|
43a3c54838 | ||
|
11eaeb719b
|
|||
|
74c2978cd1
|
|||
|
4fdc247904
|
|||
|
c3f8b4b5aa
|
|||
|
67591c0572
|
|||
|
de1f9d6ab6
|
|||
|
6acce72fb8
|
|||
|
cf8943b205
|
|||
|
9e03d36482
|
|||
|
fd7268d620
|
|||
|
6358c22a85
|
|||
|
655b6a18b2
|
|||
|
37b2bdc8bd
|
|||
|
94e01d4ea8
|
|||
|
35f5177884
|
|||
|
ac2c61f221
|
|||
|
ef3d46279f
|
|||
|
703da324fa
|
|||
|
1dae472253
|
|||
|
09fdfb88cd
|
|||
|
04b63cc7e2
|
|||
|
cb6a35606d
|
|||
|
9c9f3ba3b7
|
|||
|
08dda2b2cb
|
|||
|
059129a738
|
|||
|
437c9f4a25
|
|||
|
48bf447ce1
|
|||
|
548fa4f0a1
|
|||
|
|
642cca30ad | ||
|
|
419249aca3 | ||
|
c69cb94ac7
|
|||
|
|
4b652d885f | ||
|
381f784775
|
|||
| 91866b4e9a | |||
|
01f1568221
|
|||
|
3e8d2768b3
|
|||
|
16a832779a
|
|||
|
d582915842
|
|||
|
|
caaecd7a02 |
@@ -13,16 +13,12 @@
|
||||
"markdown": {},
|
||||
"toml": {},
|
||||
"dockerfile": {},
|
||||
"ruff": {},
|
||||
"jupyter": {},
|
||||
"malva": {},
|
||||
"markup": {
|
||||
// https://dprint.dev/plugins/markup_fmt/config/
|
||||
"scriptIndent": true,
|
||||
"styleIndent": true,
|
||||
},
|
||||
"yaml": {},
|
||||
"graphql": {},
|
||||
"exec": {
|
||||
"cwd": "${configDir}",
|
||||
"commands": [
|
||||
@@ -46,20 +42,18 @@
|
||||
"**/*-lock.yaml",
|
||||
"**/yaml.lock",
|
||||
"**/.DS_Store",
|
||||
"**/.pnpm-store",
|
||||
"**/.cargo",
|
||||
"**/target",
|
||||
],
|
||||
"plugins": [
|
||||
"https://plugins.dprint.dev/typescript-0.95.13.wasm",
|
||||
"https://plugins.dprint.dev/typescript-0.95.15.wasm",
|
||||
"https://plugins.dprint.dev/json-0.21.1.wasm",
|
||||
"https://plugins.dprint.dev/markdown-0.20.0.wasm",
|
||||
"https://plugins.dprint.dev/markdown-0.21.1.wasm",
|
||||
"https://plugins.dprint.dev/toml-0.7.0.wasm",
|
||||
"https://plugins.dprint.dev/dockerfile-0.3.3.wasm",
|
||||
"https://plugins.dprint.dev/ruff-0.6.11.wasm",
|
||||
"https://plugins.dprint.dev/jupyter-0.2.1.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/malva-v0.15.1.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/pretty_graphql-v0.2.3.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm",
|
||||
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364",
|
||||
"https://plugins.dprint.dev/biome-0.11.10.wasm",
|
||||
],
|
||||
}
|
||||
|
||||
45
.gitea/scripts/build.sh
Executable file
45
.gitea/scripts/build.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p app/static
|
||||
|
||||
cp CHANGELOG.md app/static/CHANGELOG.md
|
||||
|
||||
# Derive branch/tag info
|
||||
REF_TYPE="${GITHUB_REF_TYPE:-branch}"
|
||||
REF_NAME="${GITHUB_REF_NAME:-$(basename "$GITHUB_REF")}"
|
||||
BRANCH="${GITHUB_HEAD_REF:-}"
|
||||
if [[ -z "$BRANCH" && "$REF_TYPE" == "branch" ]]; then
|
||||
BRANCH="$REF_NAME"
|
||||
fi
|
||||
|
||||
# Determine last tag and commits since
|
||||
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
COMMITS_SINCE_LAST_RELEASE="$(git rev-list --count "${LAST_TAG}..HEAD")"
|
||||
else
|
||||
COMMITS_SINCE_LAST_RELEASE="0"
|
||||
fi
|
||||
|
||||
commit_message=$(git log -1 --pretty=%B | tr -d '\n' | sed 's/"/\\"/g')
|
||||
|
||||
cat >app/static/git.json <<EOF
|
||||
{
|
||||
"ref": "${GITHUB_REF:-}",
|
||||
"ref_name": "${REF_NAME}",
|
||||
"ref_type": "${REF_TYPE}",
|
||||
"sha": "${GITHUB_SHA:-}",
|
||||
"run_number": "${GITHUB_RUN_NUMBER:-}",
|
||||
"event_name": "${GITHUB_EVENT_NAME:-}",
|
||||
"workflow": "${GITHUB_WORKFLOW:-}",
|
||||
"job": "${GITHUB_JOB:-}",
|
||||
"commit_message": "${commit_message}",
|
||||
"commit_timestamp": "$(git log -1 --pretty=%cI)",
|
||||
"branch": "${BRANCH}",
|
||||
"commits_since_last_release": "${COMMITS_SINCE_LAST_RELEASE}"
|
||||
}
|
||||
EOF
|
||||
|
||||
pnpm build
|
||||
|
||||
cp -R packages/ui/build app/build/ui
|
||||
@@ -1,20 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
VERSION="${TAG#v}"
|
||||
VERSION=$(echo "$TAG" | sed 's/^v//')
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "🚀 Creating release for $TAG (safe mode)"
|
||||
echo "🚀 Creating release for $TAG"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 1. Extract release notes from annotated tag
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
NOTES=$(git tag -l "$TAG" --format='%(contents)')
|
||||
# Ensure the local git knows this is an annotated tag with metadata
|
||||
git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force
|
||||
|
||||
if [ -z "$NOTES" ]; then
|
||||
echo "❌ Tag message is empty"
|
||||
# %(contents) gets the whole message.
|
||||
# If you want ONLY what you typed after the first line, use %(contents:body)
|
||||
NOTES=$(git tag -l "$TAG" --format='%(contents)' | sed '/-----BEGIN PGP SIGNATURE-----/,/-----END PGP SIGNATURE-----/d')
|
||||
|
||||
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
|
||||
echo "❌ Tag message is empty or tag is not annotated"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -23,51 +28,71 @@ git checkout main
|
||||
# -------------------------------------------------------------------
|
||||
# 2. Update all package.json versions
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
echo "🔧 Updating package.json versions to $VERSION"
|
||||
|
||||
find . -name package.json -not -path "*/node_modules/*" | while read -r file; do
|
||||
jq --arg v "$VERSION" '.version = $v' "$file" >"$file.tmp"
|
||||
mv "$file.tmp" "$file"
|
||||
find . -name package.json ! -path "*/node_modules/*" | while read -r file; do
|
||||
tmp_file="$file.tmp"
|
||||
jq --arg v "$VERSION" '.version = $v' "$file" >"$tmp_file"
|
||||
mv "$tmp_file" "$file"
|
||||
done
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 3. Update CHANGELOG.md (prepend)
|
||||
# 3. Generate commit list since last release
|
||||
# -------------------------------------------------------------------
|
||||
LAST_TAG=$(git tag --sort=-creatordate | grep -v "^$TAG$" | head -n 1 || echo "")
|
||||
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
# Filter out previous 'chore(release)' commits so the list stays clean
|
||||
COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:'* [%h](https://git.max-richter.dev/max/nodarium/commit/%H) %s' | grep -v "chore(release)")
|
||||
else
|
||||
COMMITS=$(git log HEAD --pretty=format:'* [%h](https://git.max-richter.dev/max/nodarium/commit/%H) %s' | grep -v "chore(release)")
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 4. Update CHANGELOG.md (prepend)
|
||||
# -------------------------------------------------------------------
|
||||
tmp_changelog="CHANGELOG.tmp"
|
||||
{
|
||||
echo "## $TAG ($DATE)"
|
||||
echo "# $TAG ($DATE)"
|
||||
echo ""
|
||||
echo "$NOTES"
|
||||
echo ""
|
||||
echo "---"
|
||||
if [ -n "$COMMITS" ]; then
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "$COMMITS"
|
||||
echo ""
|
||||
fi
|
||||
echo ""
|
||||
cat CHANGELOG.md 2>/dev/null || true
|
||||
} >CHANGELOG.tmp
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
cat CHANGELOG.md
|
||||
fi
|
||||
} >"$tmp_changelog"
|
||||
|
||||
mv CHANGELOG.tmp CHANGELOG.md
|
||||
mv "$tmp_changelog" CHANGELOG.md
|
||||
|
||||
pnpm exec dprint fmt CHANGELOG.md
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 4. Create release commit
|
||||
# 5. Setup GPG signing
|
||||
# -------------------------------------------------------------------
|
||||
echo "$BOT_PGP_PRIVATE_KEY" | base64 -d | gpg --batch --import --
|
||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG nodarium-bot@max-richter.dev 2>/dev/null | grep sec | head -n1 | sed 's/.*\///' | tr -d ' ')
|
||||
|
||||
git config user.name "release-bot"
|
||||
git config user.email "release-bot@ci"
|
||||
git config user.name "nodarium-bot"
|
||||
git config user.email "nodarium-bot@max-richter.dev"
|
||||
git config user.signingkey "$GPG_KEY_ID"
|
||||
git config commit.gpgsign true
|
||||
|
||||
git add CHANGELOG.md $(find . -name package.json -not -path "*/node_modules/*")
|
||||
# -------------------------------------------------------------------
|
||||
# 6. Create release commit
|
||||
# -------------------------------------------------------------------
|
||||
git add CHANGELOG.md $(find . -name package.json ! -path "*/node_modules/*")
|
||||
|
||||
# Skip commit if nothing changed
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit for release $TAG"
|
||||
exit 0
|
||||
else
|
||||
git commit -m "chore(release): $TAG"
|
||||
git push origin main
|
||||
fi
|
||||
|
||||
git commit -m "chore(release): $TAG"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 5. Push changes
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
git push origin main
|
||||
|
||||
echo "✅ Release commit for $TAG created successfully (tag untouched)"
|
||||
echo "✅ Release process for $TAG complete"
|
||||
|
||||
43
.gitea/scripts/deploy-files.sh
Executable file
43
.gitea/scripts/deploy-files.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Configuring rclone"
|
||||
|
||||
KEY_FILE="$(mktemp)"
|
||||
echo "${SSH_PRIVATE_KEY}" >"${KEY_FILE}"
|
||||
chmod 600 "${KEY_FILE}"
|
||||
|
||||
mkdir -p ~/.config/rclone
|
||||
cat >~/.config/rclone/rclone.conf <<EOF
|
||||
[sftp-remote]
|
||||
type = sftp
|
||||
host = ${SSH_HOST}
|
||||
user = ${SSH_USER}
|
||||
port = ${SSH_PORT}
|
||||
key_file = ${KEY_FILE}
|
||||
EOF
|
||||
|
||||
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
|
||||
TARGET_DIR="${REMOTE_DIR}"
|
||||
elif [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" ]]; then
|
||||
SAFE_PR_NAME="${GITHUB_HEAD_REF//\//-}"
|
||||
TARGET_DIR="${REMOTE_DIR}_${SAFE_PR_NAME}"
|
||||
elif [[ "${GITHUB_REF_NAME:-}" == "main" ]]; then
|
||||
TARGET_DIR="${REMOTE_DIR}_main"
|
||||
else
|
||||
SAFE_REF="${GITHUB_REF_NAME//\//-}"
|
||||
TARGET_DIR="${REMOTE_DIR}_${SAFE_REF}"
|
||||
fi
|
||||
|
||||
echo "Deploying to ${TARGET_DIR}"
|
||||
|
||||
rclone sync \
|
||||
--update \
|
||||
--verbose \
|
||||
--progress \
|
||||
--exclude _astro/** \
|
||||
--stats 2s \
|
||||
--stats-one-line \
|
||||
--transfers 4 \
|
||||
./app/build/ \
|
||||
"sftp-remote:${TARGET_DIR}"
|
||||
41
.gitea/workflows/build-ci-image.yaml
Normal file
41
.gitea/workflows/build-ci-image.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Build & Push CI Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "Dockerfile.ci"
|
||||
- ".gitea/workflows/build-ci-image.yaml"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."git.max-richter.dev"]
|
||||
https = true
|
||||
insecure = false
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.max-richter.dev
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.ci
|
||||
push: true
|
||||
tags: |
|
||||
git.max-richter.dev/${{ gitea.repository }}-ci:latest
|
||||
git.max-richter.dev/${{ gitea.repository }}-ci:${{ gitea.sha }}
|
||||
@@ -1,24 +1,28 @@
|
||||
name: 🚀 Release
|
||||
name: 🚀 Lint & Test & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
|
||||
env:
|
||||
PNPM_CACHE_FOLDER: ~/.pnpm-store
|
||||
PNPM_CACHE_FOLDER: .pnpm-store
|
||||
CARGO_HOME: .cargo
|
||||
CARGO_TARGET_DIR: target
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
container: jimfx/nodes:latest
|
||||
container: git.max-richter.dev/max/nodarium-ci:fd7268d6208aede435e1685817ae6b271c68bd83
|
||||
|
||||
steps:
|
||||
- name: 📑 Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: 💾 Setup pnpm Cache
|
||||
uses: actions/cache@v4
|
||||
@@ -28,31 +32,52 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: 🦀 Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: 📦 Install Dependencies
|
||||
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
|
||||
|
||||
- name: 🧹 Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: 🎨 Format Check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: 🧬 Type Check
|
||||
run: pnpm check
|
||||
|
||||
- name: 🛠️ Build
|
||||
run: pnpm build:deploy
|
||||
- name: 🧹 Quality Control
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm format:check
|
||||
pnpm check
|
||||
pnpm build
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test
|
||||
|
||||
- name: 🚀 Create Release Commit
|
||||
if: github.ref_type == 'tag'
|
||||
if: gitea.ref_type == 'tag'
|
||||
run: ./.gitea/scripts/create-release.sh
|
||||
env:
|
||||
BOT_PGP_PRIVATE_KEY: ${{ secrets.BOT_PGP_PRIVATE_KEY }}
|
||||
|
||||
- name: 🛠️ Build
|
||||
run: ./.gitea/scripts/build.sh
|
||||
|
||||
- name: 🏷️ Create Gitea Release
|
||||
if: github.ref_type == 'tag'
|
||||
if: gitea.ref_type == 'tag'
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
release_name: Release ${{ github.ref_name }}
|
||||
tag_name: ${{ gitea.ref_name }}
|
||||
release_name: Release ${{ gitea.ref_name }}
|
||||
body_path: CHANGELOG.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: 🚀 Deploy Changed Files via rclone
|
||||
run: ./.gitea/scripts/deploy-files.sh
|
||||
env:
|
||||
REMOTE_DIR: ${{ vars.REMOTE_DIR }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_HOST: ${{ vars.SSH_HOST }}
|
||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
||||
SSH_USER: ${{ vars.SSH_USER }}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"name": "chokidar-cli",
|
||||
"description": "Ultra-fast cross-platform command line utility to watch file system changes.",
|
||||
"version": "0.0.4",
|
||||
"keywords": [
|
||||
"fs",
|
||||
"watch",
|
||||
"watchFile",
|
||||
"watcher",
|
||||
"watching",
|
||||
"file",
|
||||
"fsevents",
|
||||
"chokidar",
|
||||
"cli",
|
||||
"command",
|
||||
"shell",
|
||||
"bash"
|
||||
],
|
||||
"bin": {
|
||||
"chokidar": "index.js"
|
||||
},
|
||||
"homepage": "https://github.com/open-cli-tools/chokidar-cli",
|
||||
"author": "Kimmo Brunfeldt <kimmobrunfeldt@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/open-cli-tools/chokidar-cli.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "http://github.com/open-cli-tools/chokidar-cli/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.1",
|
||||
"mocha": "^11.7.2"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --report-unused-disable-directives --ignore-path .gitignore .",
|
||||
"mocha": "mocha",
|
||||
"test": "npm run lint && npm run mocha"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"files": [
|
||||
"*.js"
|
||||
],
|
||||
"eslintConfig": {
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
"sourceType": "script"
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"rules": {
|
||||
"array-callback-return": "error",
|
||||
"indent": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"no-empty": [
|
||||
"error",
|
||||
{
|
||||
"allowEmptyCatch": true
|
||||
}
|
||||
],
|
||||
"object-shorthand": "error",
|
||||
"prefer-arrow-callback": [
|
||||
"error",
|
||||
{
|
||||
"allowNamedFunctions": true
|
||||
}
|
||||
],
|
||||
"prefer-const": [
|
||||
"error",
|
||||
{
|
||||
"ignoreReadBeforeAssign": true
|
||||
}
|
||||
],
|
||||
"prefer-destructuring": [
|
||||
"error",
|
||||
{
|
||||
"object": true,
|
||||
"array": false
|
||||
}
|
||||
],
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"radix": "error",
|
||||
"strict": "error",
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"no-var": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
133
CHANGELOG.md
Normal file
133
CHANGELOG.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# v0.0.4 (2026-02-10)
|
||||
|
||||
## Features
|
||||
|
||||
- Added shape and leaf nodes, including rotation support.
|
||||
- Added high-contrast light theme and improved overall node readability.
|
||||
- Enhanced UI with dots background, clearer details, and consistent node coloring.
|
||||
- Improved changelog display and parsing robustness.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fixed UI issues (backside rendering, missing types, linter errors).
|
||||
- Improved CI handling of commit messages and changelog placement.
|
||||
|
||||
## Chores
|
||||
|
||||
- Simplified CI quality checks.
|
||||
- Updated dprint linters.
|
||||
- Refactored changelog code.
|
||||
|
||||
---
|
||||
|
||||
- [51de3ce](https://git.max-richter.dev/max/nodarium/commit/51de3ced133af07b9432e1137068ef43ddfecbc9) fix(ci): update changelog before building
|
||||
- [8d403ba](https://git.max-richter.dev/max/nodarium/commit/8d403ba8039a05b687f050993a6afca7fb743e12) Merge pull request 'feat/shape-node' (#36) from feat/shape-node into main
|
||||
- [6bb3011](https://git.max-richter.dev/max/nodarium/commit/6bb301153ac13c31511b6b28ae95c6e0d4c03e9e) Merge remote-tracking branch 'origin/main' into feat/shape-node
|
||||
- [02eee5f](https://git.max-richter.dev/max/nodarium/commit/02eee5f9bf4b1bc813d5d28673c4d5d77b392a92) fix: disable macro logs in wasm
|
||||
- [4f48a51](https://git.max-richter.dev/max/nodarium/commit/4f48a519a950123390530f1b6040e2430a767745) feat(nodes): add rotation to instance node
|
||||
- [97199ac](https://git.max-richter.dev/max/nodarium/commit/97199ac20fb079d6c157962d1a998d63670d8797) feat(nodes): implement leaf node
|
||||
- [f36f0cb](https://git.max-richter.dev/max/nodarium/commit/f36f0cb2305692c7be60889bcde7f91179e18b81) feat(ui): show circles only when hovering InputShape
|
||||
- [ed3d48e](https://git.max-richter.dev/max/nodarium/commit/ed3d48e07fa6db84bbb24db6dbe044cbc36f049f) fix(runtime): correctly encode 2d shape for wasm nodes
|
||||
- [c610d6c](https://git.max-richter.dev/max/nodarium/commit/c610d6c99152d8233235064b81503c2b0dc4ada8) fix(app): show backside in three instances
|
||||
- [8865b9b](https://git.max-richter.dev/max/nodarium/commit/8865b9b032bdf5a1385b4e9db0b1923f0e224fdd) feat(node): initial leaf / shape nodes
|
||||
- [235ee5d](https://git.max-richter.dev/max/nodarium/commit/235ee5d979fbd70b3e0fb6f09a352218c3ff1e6d) fix(app): wrong linter errors in changelog
|
||||
- [23a4857](https://git.max-richter.dev/max/nodarium/commit/23a48572f3913d91d839873cc155a16139c286a6) feat(app): dots background for node interface
|
||||
- [e89a46e](https://git.max-richter.dev/max/nodarium/commit/e89a46e146e9e95de57ffdf55b05d16d6fe975f4) feat(app): add error page
|
||||
- [cefda41](https://git.max-richter.dev/max/nodarium/commit/cefda41fcf3d5d011c9f7598a4f3f37136602dbd) feat(theme): optimize node readability
|
||||
- [21d0f0d](https://git.max-richter.dev/max/nodarium/commit/21d0f0da5a26492fa68ad4897a9b1d9e88857030) feat: add high-contrast-light theme
|
||||
- [4620245](https://git.max-richter.dev/max/nodarium/commit/46202451ba3eea73bd1bc6ef5129b3e26ee9315c) ci: simplify ci quality checks
|
||||
- [0f4239d](https://git.max-richter.dev/max/nodarium/commit/0f4239d179ddedd3d012ca98b5bc3312afbc8f10) ci: simplify ci quality checks
|
||||
- [d9c9bb5](https://git.max-richter.dev/max/nodarium/commit/d9c9bb5234bc8776daf26be99ba77a2145c70649) fix(theme): allow raw html in head style
|
||||
- [18802fd](https://git.max-richter.dev/max/nodarium/commit/18802fdc10294a58425f052a4fde4bcf4be58caf) fix(ui): add missing types
|
||||
- [b1cbd23](https://git.max-richter.dev/max/nodarium/commit/b1cbd235420c99a11154ef6a899cc7e14faf1c37) feat(app): use same color for node outline and header
|
||||
- [33f10da](https://git.max-richter.dev/max/nodarium/commit/33f10da396fdc13edcb8faaee212280102b24f3a) feat(ui): make details stand out
|
||||
- [af5b3b2](https://git.max-richter.dev/max/nodarium/commit/af5b3b23ba18d73d6abec60949fb0c9edfc25ff8) fix: make sure that CHANGELOG.md is in correct place
|
||||
- [64d75b9](https://git.max-richter.dev/max/nodarium/commit/64d75b9686c494075223a0a318297fe59ec99e81) feat(ui): add InputColor and custom theme
|
||||
- [2e6466c](https://git.max-richter.dev/max/nodarium/commit/2e6466ceca1d2131581d1862e93c756affdf6cd6) chore: update dprint linters
|
||||
- [20d8e2a](https://git.max-richter.dev/max/nodarium/commit/20d8e2abedf0de30299d947575afef9c8ffd61d9) feat(theme): improve light theme a bit
|
||||
- [715e1d0](https://git.max-richter.dev/max/nodarium/commit/715e1d095b8a77feb0cf66bbb444baf0f163adcb) feat(theme): merge edge and connection color
|
||||
- [07e2826](https://git.max-richter.dev/max/nodarium/commit/07e2826f16dafa6a07377c9fb591168fa5c2abcf) feat(ui): improve colors of input shape
|
||||
- [e0ad97b](https://git.max-richter.dev/max/nodarium/commit/e0ad97b003fd8cb4d950c03e5488a5accf6a37d0) feat(ui): highlight circle on hover on InputShape
|
||||
- [93df4a1](https://git.max-richter.dev/max/nodarium/commit/93df4a19ff816e2bdfa093594721f0829f84c9e6) fix(ci): handle newline in commit messages for git.json
|
||||
- [d661a4e](https://git.max-richter.dev/max/nodarium/commit/d661a4e4a9dfa6c9c73b5e24a3edcf56e1bbf48c) feat(ui): improve InputShape ux
|
||||
- [c7f808c](https://git.max-richter.dev/max/nodarium/commit/c7f808ce2d52925425b49f92edf49d9557f8901d) wip
|
||||
- [72d6cd6](https://git.max-richter.dev/max/nodarium/commit/72d6cd6ea2886626823e6e86856f19338c7af3c1) feat(ui): add initial InputShape element
|
||||
- [615f2d3](https://git.max-richter.dev/max/nodarium/commit/615f2d3c4866a9e85f3eca398f3f02100c4df355) feat(ui): allow custom snippets in ui section header
|
||||
- [2fadb68](https://git.max-richter.dev/max/nodarium/commit/2fadb6802de640d692fdab7d654311df0d7b4836) refactor: make changelog code simpler
|
||||
- [9271d3a](https://git.max-richter.dev/max/nodarium/commit/9271d3a7e4cb0cc751b635c2adb518de7b4100c7) fix(app): handle error while parsing commit
|
||||
- [13c83ef](https://git.max-richter.dev/max/nodarium/commit/13c83efdb962a6578ade59f10cc574fef0e17534) fix(app): handle error while parsing changelog
|
||||
- [e44b73b](https://git.max-richter.dev/max/nodarium/commit/e44b73bebfb1cc8e872cd2fa7d8b6ff3565df374) feat: optimize changelog display
|
||||
- [979e9fd](https://git.max-richter.dev/max/nodarium/commit/979e9fd92289eba9f77221c563337c00028e4cf5) feat: improve changelog readbility
|
||||
- [544500e](https://git.max-richter.dev/max/nodarium/commit/544500e7fe9ee14412cef76f3c7a32ba6f291656) chore: remove pgp from changelog
|
||||
- [aaebbc4](https://git.max-richter.dev/max/nodarium/commit/aaebbc4bc082ee93c2317ce45071c9bc61b0b77e) fix: some stuff with ci
|
||||
|
||||
# v0.0.3 (2026-02-07)
|
||||
|
||||
## Features
|
||||
|
||||
- Edge dragging now highlights valid connection sockets, improving graph editing clarity.
|
||||
- InputNumber supports snapping to predefined values while holding Alt.
|
||||
- Changelog is accessible directly from the sidebar and now includes git metadata and a list of commits.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fixed incorrect socket highlighting when an edge already existed.
|
||||
- Corrected initialization of `InputNumber` values outside min/max bounds.
|
||||
- Fixed initialization of nested vec3 inputs.
|
||||
- Multiple CI fixes to ensure reliable builds, correct environment variables, and proper image handling.
|
||||
|
||||
## Maintenance / CI
|
||||
|
||||
- Significant CI and Dockerfile cleanup and optimization.
|
||||
- Improved git metadata generation during builds.
|
||||
- Dependency updates, formatting, and test snapshot updates.
|
||||
|
||||
---
|
||||
|
||||
- [f8a2a95](https://git.max-richter.dev/max/nodarium/commit/f8a2a95bc18fa3c8c1db67dc0c2b66db1ff0d866) chore: clean CHANGELOG.md
|
||||
- [c9dd143](https://git.max-richter.dev/max/nodarium/commit/c9dd143916d758991f3ba30723a32c18b6f98bb5) fix(ci): correctly add release notes from tag to changelog
|
||||
- [898dd49](https://git.max-richter.dev/max/nodarium/commit/898dd49aee930350af8645382ef5042765a1fac7) fix(ci): correctly copy changelog to build output
|
||||
- [9fb69d7](https://git.max-richter.dev/max/nodarium/commit/9fb69d760fdf92ecc2448e468242970ec48443b0) feat: show commits since last release in changelog
|
||||
- [bafbcca](https://git.max-richter.dev/max/nodarium/commit/bafbcca2b8a7cd9f76e961349f11ec84d1e4da63) fix: wrong socket was highlighted when dragging node
|
||||
- [8ad9e55](https://git.max-richter.dev/max/nodarium/commit/8ad9e5535cd752ef111504226b4dac57b5adcf3d) feat: highlight possible sockets when dragging edge
|
||||
- [11eaeb7](https://git.max-richter.dev/max/nodarium/commit/11eaeb719be7f34af8db8b7908008a15308c0cac) feat(app): display some git metadata in changelog
|
||||
- [74c2978](https://git.max-richter.dev/max/nodarium/commit/74c2978cd16d2dd95ce1ae8019dfb9098e52b4b6) chore: cleanup git.json a bit
|
||||
- [4fdc247](https://git.max-richter.dev/max/nodarium/commit/4fdc24790490d3f13ee94a557159617f4077a2f9) ci: update build.sh to correct git.json
|
||||
- [c3f8b4b](https://git.max-richter.dev/max/nodarium/commit/c3f8b4b5aad7a525fb11ab14c9236374cb60442d) ci: debug available env vars
|
||||
- [67591c0](https://git.max-richter.dev/max/nodarium/commit/67591c0572b873d8c7cd00db8efb7dac2d6d4de2) chore: pnpm format
|
||||
- [de1f9d6](https://git.max-richter.dev/max/nodarium/commit/de1f9d6ab669b8e699d98b8855e125e21030b5b3) feat(ui): change inputnumber to snap to values when alt is pressed
|
||||
- [6acce72](https://git.max-richter.dev/max/nodarium/commit/6acce72fb8c416cc7f6eec99c2ae94d6529e960c) fix(ui): correctly initialize InputNumber
|
||||
- [cf8943b](https://git.max-richter.dev/max/nodarium/commit/cf8943b2059aa286e41865caf75058d35498daf7) chore: pnpm update
|
||||
- [9e03d36](https://git.max-richter.dev/max/nodarium/commit/9e03d36482bb4f972c384b66b2dcf258f0cd18be) chore: use newest ci image
|
||||
- [fd7268d](https://git.max-richter.dev/max/nodarium/commit/fd7268d6208aede435e1685817ae6b271c68bd83) ci: make dockerfile work
|
||||
- [6358c22](https://git.max-richter.dev/max/nodarium/commit/6358c22a853ec340be5223fabb8289092e4f4afe) ci: use tagged own image for ci
|
||||
- [655b6a1](https://git.max-richter.dev/max/nodarium/commit/655b6a18b282f0cddcc750892e575ee6c311036b) ci: make dockerfile work
|
||||
- [37b2bdc](https://git.max-richter.dev/max/nodarium/commit/37b2bdc8bdbd8ded6b22b89214b49de46f788351) ci: update ci Dockerfile to work
|
||||
- [94e01d4](https://git.max-richter.dev/max/nodarium/commit/94e01d4ea865f15ce06b52827a1ae6906de5be5e) ci: correctly build and push ci image
|
||||
- [35f5177](https://git.max-richter.dev/max/nodarium/commit/35f5177884b62bbf119af1bbf4df61dd0291effb) feat: try to optimize the Dockerfile
|
||||
- [ac2c61f](https://git.max-richter.dev/max/nodarium/commit/ac2c61f2211ba96bbdbb542179905ca776537cec) ci: use actual git url in ci
|
||||
- [ef3d462](https://git.max-richter.dev/max/nodarium/commit/ef3d46279f4ff9c04d80bb2d9a9e7cfec63b224e) fix(ci): build before testing
|
||||
- [703da32](https://git.max-richter.dev/max/nodarium/commit/703da324fabbef0e2c017f0f7a925209fa26bd03) ci: automatically build ci image and store locally
|
||||
- [1dae472](https://git.max-richter.dev/max/nodarium/commit/1dae472253ccb5e3766f2270adc053b922f46738) ci: add a git.json metadata file during build
|
||||
- [09fdfb8](https://git.max-richter.dev/max/nodarium/commit/09fdfb88cd203ace0e36663ebdb2c8c7ba53f190) chore: update test screenshots
|
||||
- [04b63cc](https://git.max-richter.dev/max/nodarium/commit/04b63cc7e2fc4fcfa0973cf40592d11457179db3) feat: add changelog to sidebar
|
||||
- [cb6a356](https://git.max-richter.dev/max/nodarium/commit/cb6a35606dfda50b0c81b04902d7a6c8e59458d2) feat(ci): also cache cargo stuff
|
||||
- [9c9f3ba](https://git.max-richter.dev/max/nodarium/commit/9c9f3ba3b7c94215a86b0a338a5cecdd87b96b28) fix(ci): use GITHUB_instead of GITEA_ for env vars
|
||||
- [08dda2b](https://git.max-richter.dev/max/nodarium/commit/08dda2b2cb4d276846abe30bc260127626bb508a) chore: pnpm format
|
||||
- [059129a](https://git.max-richter.dev/max/nodarium/commit/059129a738d02b8b313bb301a515697c7c4315ac) fix(ci): deploy prs and main
|
||||
- [437c9f4](https://git.max-richter.dev/max/nodarium/commit/437c9f4a252125e1724686edace0f5f006f58439) feat(ci): add list of all commits to changelog entry
|
||||
- [48bf447](https://git.max-richter.dev/max/nodarium/commit/48bf447ce12949d7c29a230806d160840b7847e1) docs: straighten up changelog a bit
|
||||
- [548fa4f](https://git.max-richter.dev/max/nodarium/commit/548fa4f0a1a14adc40a74da1182fa6da81eab3df) fix(app): correctly initialize vec3 inputs in nestedsettings
|
||||
|
||||
# v0.0.2 (2026-02-04)
|
||||
|
||||
## Fixes
|
||||
|
||||
---
|
||||
|
||||
- []() fix(ci): actually deploy on tags
|
||||
- []() fix(app): correctly handle false value in settings
|
||||
|
||||
# v0.0.1 (2026-02-03)
|
||||
|
||||
chore: format
|
||||
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -62,6 +62,14 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "leaf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"nodarium_macros",
|
||||
"nodarium_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "math"
|
||||
version = "0.1.0"
|
||||
@@ -245,6 +253,14 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shape"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"nodarium_macros",
|
||||
"nodarium_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stem"
|
||||
version = "0.1.0"
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,15 +0,0 @@
|
||||
FROM node:24-alpine
|
||||
|
||||
RUN apk add --no-cache --update curl rclone g++
|
||||
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
|
||||
RUN curl --silent --show-error --location --fail --retry 3 \
|
||||
--proto '=https' --tlsv1.2 \
|
||||
--output /tmp/rustup-init.sh https://sh.rustup.rs \
|
||||
&& sh /tmp/rustup-init.sh -y --no-modify-path --profile minimal \
|
||||
&& rm /tmp/rustup-init.sh \
|
||||
&& rustup target add wasm32-unknown-unknown \
|
||||
&& npm i -g pnpm
|
||||
30
Dockerfile.ci
Normal file
30
Dockerfile.ci
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM node:25-bookworm-slim
|
||||
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates=20230311+deb12u1 \
|
||||
curl=7.88.1-10+deb12u14 \
|
||||
git=1:2.39.5-0+deb12u3 \
|
||||
jq=1.6-2.1+deb12u1 \
|
||||
g++=4:12.2.0-3 \
|
||||
rclone=1.60.1+dfsg-2+b5 \
|
||||
xvfb=2:21.1.7-3+deb12u11 \
|
||||
xauth=1:1.1.2-1 \
|
||||
--no-install-recommends \
|
||||
# Install Rust
|
||||
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
|
||||
--default-toolchain stable \
|
||||
--profile minimal \
|
||||
&& rustup target add wasm32-unknown-unknown \
|
||||
# Setup Playwright
|
||||
&& npm i -g pnpm \
|
||||
&& pnpm dlx playwright install --with-deps firefox \
|
||||
# Final Cleanup
|
||||
&& rm -rf /usr/local/rustup/downloads /usr/local/rustup/tmp \
|
||||
&& rm -rf /usr/local/cargo/registry/index /usr/local/cargo/registry/cache \
|
||||
&& rm -rf /usr/local/rustup/toolchains/*/share/doc \
|
||||
&& apt-get purge -y --auto-remove \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
2
app/.gitignore
vendored
2
app/.gitignore
vendored
@@ -27,3 +27,5 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
build/
|
||||
|
||||
test-results/
|
||||
|
||||
62
app/e2e/main.test.ts
Normal file
62
app/e2e/main.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
// Listen for console messages
|
||||
page.on('console', msg => {
|
||||
console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:4173', { waitUntil: 'load' });
|
||||
|
||||
// await expect(page).toHaveScreenshot();
|
||||
await expect(page.locator('.graph-wrapper')).toHaveScreenshot();
|
||||
|
||||
await page.getByRole('button', { name: 'projects' }).click();
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
await page.getByRole('combobox').selectOption('2');
|
||||
await page.getByRole('textbox', { name: 'Project name' }).click();
|
||||
await page.getByRole('textbox', { name: 'Project name' }).fill('Test Project');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
const expectedNodes = [
|
||||
{
|
||||
id: '10',
|
||||
type: 'max/plantarium/stem',
|
||||
props: {
|
||||
amount: 50,
|
||||
length: 4,
|
||||
thickness: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
type: 'max/plantarium/noise',
|
||||
props: {
|
||||
scale: 0.5,
|
||||
strength: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
type: 'max/plantarium/output'
|
||||
}
|
||||
];
|
||||
|
||||
for (const node of expectedNodes) {
|
||||
const wrapper = page.locator(
|
||||
`div.wrapper[data-node-id="${node.id}"][data-node-type="${node.type}"]`
|
||||
);
|
||||
await expect(wrapper).toBeVisible();
|
||||
if ('props' in node) {
|
||||
const props = node.props as unknown as Record<string, number>;
|
||||
for (const propId in node.props) {
|
||||
const expectedValue = props[propId];
|
||||
const inputElement = page.locator(
|
||||
`div.wrapper[data-node-type="${node.type}"][data-node-input="${propId}"] input[type="number"]`
|
||||
);
|
||||
const value = parseFloat(await inputElement.inputValue());
|
||||
expect(value).toBe(expectedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
BIN
app/e2e/main.test.ts-snapshots/test-1-linux.png
Normal file
BIN
app/e2e/main.test.ts-snapshots/test-1-linux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@nodarium/app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"predev": "rm static/CHANGELOG.md && ln -s ../../CHANGELOG.md static/CHANGELOG.md",
|
||||
"build": "svelte-kit sync && vite build",
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run && npm run test:e2e",
|
||||
"test:e2e": "playwright test",
|
||||
"preview": "vite preview",
|
||||
"format": "dprint fmt -c '../.dprint.jsonc' .",
|
||||
"format:check": "dprint check -c '../.dprint.jsonc' .",
|
||||
@@ -16,7 +19,7 @@
|
||||
"dependencies": {
|
||||
"@nodarium/ui": "workspace:*",
|
||||
"@nodarium/utils": "workspace:*",
|
||||
"@sveltejs/kit": "^2.50.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@threlte/core": "8.3.1",
|
||||
"@threlte/extras": "9.7.1",
|
||||
@@ -24,27 +27,29 @@
|
||||
"file-saver": "^2.0.5",
|
||||
"idb": "^8.0.3",
|
||||
"jsondiffpatch": "^0.7.3",
|
||||
"micromark": "^4.0.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"three": "^0.182.0",
|
||||
"wabt": "^1.0.39"
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@iconify-json/tabler": "^1.2.26",
|
||||
"@iconify/tailwind4": "^1.2.1",
|
||||
"@nodarium/types": "workspace:",
|
||||
"@nodarium/types": "workspace:^",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tsconfig/svelte": "^5.0.6",
|
||||
"@tsconfig/svelte": "^5.0.7",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/three": "^0.182.0",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"dprint": "^0.51.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-svelte": "^3.14.0",
|
||||
"globals": "^17.3.0",
|
||||
"svelte": "^5.46.4",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte": "^5.49.2",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
@@ -52,6 +57,7 @@
|
||||
"vite-plugin-comlink": "^5.3.0",
|
||||
"vite-plugin-glsl": "^1.5.5",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vitest": "^4.0.17"
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-browser-svelte": "^2.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
20
app/playwright.config.ts
Normal file
20
app/playwright.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
webServer: { command: 'pnpm build && pnpm preview', port: 4173 },
|
||||
testDir: 'e2e',
|
||||
use: {
|
||||
browserName: 'firefox',
|
||||
launchOptions: {
|
||||
firefoxUserPrefs: {
|
||||
// Force WebGL even without a GPU
|
||||
'webgl.force-enabled': true,
|
||||
'webgl.disabled': false,
|
||||
// Use software rendering (Mesa) instead of hardware
|
||||
'layers.acceleration.disabled': true,
|
||||
'gfx.webrender.software': true,
|
||||
'webgl.enable-webgl2': true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
@source "../../packages/ui/**/*.svelte";
|
||||
@plugin "@iconify/tailwind4" {
|
||||
prefix: "i";
|
||||
icon-sets: from-folder(custom, "./src/lib/icons");
|
||||
icon-sets: from-folder("custom", "./src/lib/icons");
|
||||
}
|
||||
|
||||
body * {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/svelte.svg" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<title>Nodes</title>
|
||||
|
||||
@@ -11,6 +11,7 @@ uniform vec3 camPos;
|
||||
uniform vec2 zoomLimits;
|
||||
uniform vec3 backgroundColor;
|
||||
uniform vec3 lineColor;
|
||||
uniform int gridType; // 0 = grid lines, 1 = dots
|
||||
|
||||
// Anti-aliased step: threshold in the same units as `value`
|
||||
float aaStep(float threshold, float value, float deriv) {
|
||||
@@ -78,35 +79,51 @@ void main(void) {
|
||||
float ux = (vUv.x - 0.5) * width + cx * cz;
|
||||
float uy = (vUv.y - 0.5) * height - cy * cz;
|
||||
|
||||
// extra small grid
|
||||
float m1 = grid(ux, uy, divisions * 4.0, thickness * 4.0) * 0.9;
|
||||
float m2 = grid(ux, uy, divisions * 16.0, thickness * 16.0) * 0.5;
|
||||
float xsmall = max(m1, m2);
|
||||
|
||||
float s3 = circle_grid(ux, uy, cz / 1.6, 1.0) * 0.5;
|
||||
xsmall = max(xsmall, s3);
|
||||
if(gridType == 0) {
|
||||
// extra small grid
|
||||
float m1 = grid(ux, uy, divisions * 4.0, thickness * 4.0) * 0.9;
|
||||
float m2 = grid(ux, uy, divisions * 16.0, thickness * 16.0) * 0.5;
|
||||
float xsmall = max(m1, m2);
|
||||
|
||||
float s3 = circle_grid(ux, uy, cz / 1.6, 1.0) * 0.5;
|
||||
xsmall = max(xsmall, s3);
|
||||
|
||||
// small grid
|
||||
float c1 = grid(ux, uy, divisions, thickness) * 0.6;
|
||||
float c2 = grid(ux, uy, divisions * 2.0, thickness * 2.0) * 0.5;
|
||||
float small = max(c1, c2);
|
||||
// small grid
|
||||
float c1 = grid(ux, uy, divisions, thickness) * 0.6;
|
||||
float c2 = grid(ux, uy, divisions * 2.0, thickness * 2.0) * 0.5;
|
||||
float small = max(c1, c2);
|
||||
|
||||
float s1 = circle_grid(ux, uy, cz * 10.0, 2.0) * 0.5;
|
||||
small = max(small, s1);
|
||||
float s1 = circle_grid(ux, uy, cz * 10.0, 2.0) * 0.5;
|
||||
small = max(small, s1);
|
||||
|
||||
// large grid
|
||||
float c3 = grid(ux, uy, divisions / 8.0, thickness / 8.0) * 0.5;
|
||||
float c4 = grid(ux, uy, divisions / 2.0, thickness / 4.0) * 0.4;
|
||||
float large = max(c3, c4);
|
||||
// large grid
|
||||
float c3 = grid(ux, uy, divisions / 8.0, thickness / 8.0) * 0.5;
|
||||
float c4 = grid(ux, uy, divisions / 2.0, thickness / 4.0) * 0.4;
|
||||
float large = max(c3, c4);
|
||||
|
||||
float s2 = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
|
||||
large = max(large, s2);
|
||||
float s2 = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
|
||||
large = max(large, s2);
|
||||
|
||||
float c = mix(large, small, min(nz * 2.0 + 0.05, 1.0));
|
||||
c = mix(c, xsmall, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
|
||||
float c = mix(large, small, min(nz * 2.0 + 0.05, 1.0));
|
||||
c = mix(c, xsmall, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
|
||||
|
||||
vec3 color = mix(backgroundColor, lineColor, c);
|
||||
vec3 color = mix(backgroundColor, lineColor, c);
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
} else {
|
||||
float large = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
|
||||
|
||||
float medium = circle_grid(ux, uy, cz * 10.0, 1.0) * 0.6;
|
||||
|
||||
float small = circle_grid(ux, uy, cz * 2.5, 1.0) * 0.8;
|
||||
|
||||
float c = mix(large, medium, min(nz * 2.0 + 0.05, 1.0));
|
||||
c = mix(c, small, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
|
||||
|
||||
vec3 color = mix(backgroundColor, lineColor, c);
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
import BackgroundVert from './Background.vert';
|
||||
|
||||
type Props = {
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
cameraPosition: [number, number, number];
|
||||
width: number;
|
||||
height: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
cameraPosition?: [number, number, number];
|
||||
width?: number;
|
||||
height?: number;
|
||||
type?: 'grid' | 'dots' | 'none';
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -18,9 +19,18 @@
|
||||
maxZoom = 150,
|
||||
cameraPosition = [0, 1, 0],
|
||||
width = globalThis?.innerWidth || 100,
|
||||
height = globalThis?.innerHeight || 100
|
||||
height = globalThis?.innerHeight || 100,
|
||||
type = 'grid'
|
||||
}: Props = $props();
|
||||
|
||||
const typeMap = new Map([
|
||||
['grid', 0],
|
||||
['dots', 1],
|
||||
['none', 2]
|
||||
]);
|
||||
|
||||
const gridType = $derived(typeMap.get(type) || 0);
|
||||
|
||||
let bw = $derived(width / cameraPosition[2]);
|
||||
let bh = $derived(height / cameraPosition[2]);
|
||||
</script>
|
||||
@@ -51,6 +61,9 @@
|
||||
},
|
||||
dimensions: {
|
||||
value: [100, 100]
|
||||
},
|
||||
gridType: {
|
||||
value: 0
|
||||
}
|
||||
}}
|
||||
uniforms.camPos.value={cameraPosition}
|
||||
@@ -59,6 +72,7 @@
|
||||
uniforms.lineColor.value={appSettings.value.theme && colors['outline']}
|
||||
uniforms.zoomLimits.value={[minZoom, maxZoom]}
|
||||
uniforms.dimensions.value={[width, height]}
|
||||
uniforms.gridType.value={gridType}
|
||||
/>
|
||||
</T.Mesh>
|
||||
</T.Group>
|
||||
|
||||
@@ -5,19 +5,33 @@
|
||||
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||
|
||||
type Props = {
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
onnode: (n: NodeInstance) => void;
|
||||
};
|
||||
|
||||
const { onnode }: Props = $props();
|
||||
const padding = 10;
|
||||
|
||||
const {
|
||||
paddingLeft = padding,
|
||||
paddingRight = padding,
|
||||
paddingTop = padding,
|
||||
paddingBottom = padding,
|
||||
onnode
|
||||
}: Props = $props();
|
||||
|
||||
const graph = getGraphManager();
|
||||
const graphState = getGraphState();
|
||||
|
||||
let input: HTMLInputElement;
|
||||
let wrapper: HTMLDivElement;
|
||||
let value = $state<string>();
|
||||
let activeNodeId = $state<NodeId>();
|
||||
|
||||
const MENU_WIDTH = 150;
|
||||
const MENU_HEIGHT = 350;
|
||||
|
||||
const allNodes = graphState.activeSocket
|
||||
? graph.getPossibleNodes(graphState.activeSocket)
|
||||
: graph.getNodeDefinitions();
|
||||
@@ -79,19 +93,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clampAddMenuPosition() {
|
||||
if (!graphState.addMenuPosition) return;
|
||||
|
||||
const camX = graphState.cameraPosition[0];
|
||||
const camY = graphState.cameraPosition[1];
|
||||
const zoom = graphState.cameraPosition[2];
|
||||
|
||||
const halfViewportWidth = (graphState.width / 2) / zoom;
|
||||
const halfViewportHeight = (graphState.height / 2) / zoom;
|
||||
|
||||
const halfMenuWidth = (MENU_WIDTH / 2) / zoom;
|
||||
const halfMenuHeight = (MENU_HEIGHT / 2) / zoom;
|
||||
|
||||
const minX = camX - halfViewportWidth - halfMenuWidth + paddingLeft / zoom;
|
||||
const maxX = camX + halfViewportWidth - halfMenuWidth - paddingRight / zoom;
|
||||
const minY = camY - halfViewportHeight - halfMenuHeight + paddingTop / zoom;
|
||||
const maxY = camY + halfViewportHeight - halfMenuHeight - paddingBottom / zoom;
|
||||
|
||||
const clampedX = Math.max(
|
||||
minX + halfMenuWidth,
|
||||
Math.min(graphState.addMenuPosition[0], maxX - halfMenuWidth)
|
||||
);
|
||||
const clampedY = Math.max(
|
||||
minY + halfMenuHeight,
|
||||
Math.min(graphState.addMenuPosition[1], maxY - halfMenuHeight)
|
||||
);
|
||||
|
||||
if (clampedX !== graphState.addMenuPosition[0] || clampedY !== graphState.addMenuPosition[1]) {
|
||||
graphState.addMenuPosition = [clampedX, clampedY];
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const pos = graphState.addMenuPosition;
|
||||
const zoom = graphState.cameraPosition[2];
|
||||
const width = graphState.width;
|
||||
const height = graphState.height;
|
||||
|
||||
if (pos && zoom && width && height) {
|
||||
clampAddMenuPosition();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
input.disabled = false;
|
||||
setTimeout(() => input.focus(), 50);
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const deltaY = rect.bottom - window.innerHeight;
|
||||
const deltaX = rect.right - window.innerWidth;
|
||||
if (deltaY > 0) {
|
||||
wrapper.style.marginTop = `-${deltaY + 30}px`;
|
||||
}
|
||||
if (deltaX > 0) {
|
||||
wrapper.style.marginLeft = `-${deltaX + 30}px`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -100,7 +147,7 @@
|
||||
position.z={graphState.addMenuPosition?.[1]}
|
||||
transform={false}
|
||||
>
|
||||
<div class="add-menu-wrapper" bind:this={wrapper}>
|
||||
<div class="add-menu-wrapper">
|
||||
<div class="header">
|
||||
<input
|
||||
id="add-menu"
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
import { colors } from '../graph/colors.svelte';
|
||||
|
||||
const circleMaterial = new MeshBasicMaterial({
|
||||
color: colors.edge.clone(),
|
||||
color: colors.outline.clone(),
|
||||
toneMapped: false
|
||||
});
|
||||
|
||||
let lineColor = $state(colors.edge.clone().convertSRGBToLinear());
|
||||
let lineColor = $state(colors.outline.clone().convertSRGBToLinear());
|
||||
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
if (appSettings.value.theme === undefined) {
|
||||
return;
|
||||
}
|
||||
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
|
||||
lineColor = colors.edge.clone().convertSRGBToLinear();
|
||||
circleMaterial.color = colors.outline.clone().convertSRGBToLinear();
|
||||
lineColor = colors.outline.clone().convertSRGBToLinear();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
265
app/src/lib/graph-interface/graph-manager.svelte.test.ts
Normal file
265
app/src/lib/graph-interface/graph-manager.svelte.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GraphManager } from './graph-manager.svelte';
|
||||
import {
|
||||
createMockNodeRegistry,
|
||||
mockFloatInputNode,
|
||||
mockFloatOutputNode,
|
||||
mockGeometryOutputNode,
|
||||
mockPathInputNode,
|
||||
mockVec3OutputNode
|
||||
} from './test-utils';
|
||||
|
||||
describe('GraphManager', () => {
|
||||
describe('getPossibleSockets', () => {
|
||||
describe('when dragging an output socket', () => {
|
||||
it('should return compatible input sockets based on type', () => {
|
||||
const registry = createMockNodeRegistry([
|
||||
mockFloatOutputNode,
|
||||
mockFloatInputNode,
|
||||
mockGeometryOutputNode,
|
||||
mockPathInputNode
|
||||
]);
|
||||
|
||||
const manager = new GraphManager(registry);
|
||||
|
||||
const floatInputNode = manager.createNode({
|
||||
type: 'test/node/input',
|
||||
position: [100, 100],
|
||||
props: {}
|
||||
});
|
||||
|
||||
const floatOutputNode = manager.createNode({
|
||||
type: 'test/node/output',
|
||||
position: [0, 0],
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(floatInputNode).toBeDefined();
|
||||
expect(floatOutputNode).toBeDefined();
|
||||
|
||||
const possibleSockets = manager.getPossibleSockets({
|
||||
node: floatOutputNode!,
|
||||
index: 0,
|
||||
position: [0, 0]
|
||||
});
|
||||
|
||||
expect(possibleSockets.length).toBe(1);
|
||||
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||
expect(socketNodeIds).toContain(floatInputNode!.id);
|
||||
});
|
||||
|
||||
it('should exclude self node from possible sockets', () => {
|
||||
const registry = createMockNodeRegistry([
|
||||
mockFloatOutputNode,
|
||||
mockFloatInputNode
|
||||
]);
|
||||
|
||||
const manager = new GraphManager(registry);
|
||||
|
||||
const floatInputNode = manager.createNode({
|
||||
type: 'test/node/input',
|
||||
position: [100, 100],
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(floatInputNode).toBeDefined();
|
||||
|
||||
const possibleSockets = manager.getPossibleSockets({
|
||||
node: floatInputNode!,
|
||||
index: 'value',
|
||||
position: [0, 0]
|
||||
});
|
||||
|
||||
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||
expect(socketNodeIds).not.toContain(floatInputNode!.id);
|
||||
});
|
||||
|
||||
it('should exclude parent nodes from possible sockets when dragging output', () => {
|
||||
const registry = createMockNodeRegistry([
|
||||
mockFloatOutputNode,
|
||||
mockFloatInputNode
|
||||
]);
|
||||
|
||||
const manager = new GraphManager(registry);
|
||||
|
||||
const parentNode = manager.createNode({
|
||||
type: 'test/node/output',
|
||||
position: [0, 0],
|
||||
props: {}
|
||||
});
|
||||
|
||||
const childNode = manager.createNode({
|
||||
type: 'test/node/input',
|
||||
position: [100, 100],
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(parentNode).toBeDefined();
|
||||
expect(childNode).toBeDefined();
|
||||
|
||||
if (parentNode && childNode) {
|
||||
manager.createEdge(parentNode, 0, childNode, 'value');
|
||||
}
|
||||
|
||||
const possibleSockets = manager.getPossibleSockets({
|
||||
node: parentNode!,
|
||||
index: 0,
|
||||
position: [0, 0]
|
||||
});
|
||||
|
||||
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||
expect(socketNodeIds).not.toContain(childNode!.id);
|
||||
});
|
||||
|
||||
it('should return sockets compatible with accepts property', () => {
|
||||
const registry = createMockNodeRegistry([
|
||||
mockGeometryOutputNode,
|
||||
mockPathInputNode
|
||||
]);
|
||||
|
||||
const manager = new GraphManager(registry);
|
||||
|
||||
const geometryOutputNode = manager.createNode({
|
||||
type: 'test/node/geometry',
|
||||
position: [0, 0],
|
||||
props: {}
|
||||
});
|
||||
|
||||
const pathInputNode = manager.createNode({
|
||||
type: 'test/node/path',
|
||||
position: [100, 100],
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(geometryOutputNode).toBeDefined();
|
||||
expect(pathInputNode).toBeDefined();
|
||||
|
||||
const possibleSockets = manager.getPossibleSockets({
|
||||
node: geometryOutputNode!,
|
||||
index: 0,
|
||||
position: [0, 0]
|
||||
});
|
||||
|
||||
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||
expect(socketNodeIds).toContain(pathInputNode!.id);
|
||||
});
|
||||
|
||||
it('should return empty array when no compatible sockets exist', () => {
|
||||
const registry = createMockNodeRegistry([
|
||||
mockVec3OutputNode,
|
||||
mockFloatInputNode
|
||||
]);
|
||||
|
||||
const manager = new GraphManager(registry);
|
||||
|
||||
const vec3OutputNode = manager.createNode({
|
||||
type: 'test/node/vec3',
|
||||
position: [0, 0],
|
||||
props: {}
|
||||
});
|
||||
|
||||
const floatInputNode = manager.createNode({
|
||||
type: 'test/node/input',
|
||||
position: [100, 100],
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(vec3OutputNode).toBeDefined();
|
||||
expect(floatInputNode).toBeDefined();
|
||||
|
||||
const possibleSockets = manager.getPossibleSockets({
|
||||
node: vec3OutputNode!,
|
||||
index: 0,
|
||||
position: [0, 0]
|
||||
});
|
||||
|
||||
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||
expect(socketNodeIds).not.toContain(floatInputNode!.id);
|
||||
expect(possibleSockets.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return socket info with correct socket key for inputs', () => {
|
||||
const registry = createMockNodeRegistry([
|
||||
mockFloatOutputNode,
|
||||
mockFloatInputNode
|
||||
]);
|
||||
|
||||
const manager = new GraphManager(registry);
|
||||
|
||||
const floatOutputNode = manager.createNode({
|
||||
type: 'test/node/output',
|
||||
position: [0, 0],
|
||||
props: {}
|
||||
});
|
||||
|
||||
const floatInputNode = manager.createNode({
|
||||
type: 'test/node/input',
|
||||
position: [100, 100],
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(floatOutputNode).toBeDefined();
|
||||
expect(floatInputNode).toBeDefined();
|
||||
|
||||
const possibleSockets = manager.getPossibleSockets({
|
||||
node: floatOutputNode!,
|
||||
index: 0,
|
||||
position: [0, 0]
|
||||
});
|
||||
|
||||
const matchingSocket = possibleSockets.find(([node]) => node.id === floatInputNode!.id);
|
||||
expect(matchingSocket).toBeDefined();
|
||||
expect(matchingSocket![1]).toBe('value');
|
||||
});
|
||||
|
||||
it('should return multiple compatible sockets', () => {
|
||||
const registry = createMockNodeRegistry([
|
||||
mockFloatOutputNode,
|
||||
mockFloatInputNode,
|
||||
mockGeometryOutputNode,
|
||||
mockPathInputNode
|
||||
]);
|
||||
|
||||
const manager = new GraphManager(registry);
|
||||
|
||||
const floatOutputNode = manager.createNode({
|
||||
type: 'test/node/output',
|
||||
position: [0, 0],
|
||||
props: {}
|
||||
});
|
||||
|
||||
const geometryOutputNode = manager.createNode({
|
||||
type: 'test/node/geometry',
|
||||
position: [200, 0],
|
||||
props: {}
|
||||
});
|
||||
|
||||
const floatInputNode = manager.createNode({
|
||||
type: 'test/node/input',
|
||||
position: [100, 100],
|
||||
props: {}
|
||||
});
|
||||
|
||||
const pathInputNode = manager.createNode({
|
||||
type: 'test/node/path',
|
||||
position: [300, 100],
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(floatOutputNode).toBeDefined();
|
||||
expect(geometryOutputNode).toBeDefined();
|
||||
expect(floatInputNode).toBeDefined();
|
||||
expect(pathInputNode).toBeDefined();
|
||||
|
||||
const possibleSocketsForFloat = manager.getPossibleSockets({
|
||||
node: floatOutputNode!,
|
||||
index: 0,
|
||||
position: [0, 0]
|
||||
});
|
||||
|
||||
expect(possibleSocketsForFloat.length).toBe(1);
|
||||
expect(possibleSocketsForFloat.map(([n]) => n.id)).toContain(floatInputNode!.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -757,12 +757,16 @@ export class GraphManager extends EventEmitter<{
|
||||
(n) => n.id !== node.id && !parents.has(n.id)
|
||||
);
|
||||
|
||||
// get edges from this socket
|
||||
const edges = new SvelteMap(
|
||||
this.getEdgesFromNode(node)
|
||||
.filter((e) => e[1] === index)
|
||||
.map((e) => [e[2].id, e[3]])
|
||||
);
|
||||
const edges = new SvelteMap<number, string[]>();
|
||||
this.getEdgesFromNode(node)
|
||||
.filter((e) => e[1] === index)
|
||||
.forEach((e) => {
|
||||
if (edges.has(e[2].id)) {
|
||||
edges.get(e[2].id)?.push(e[3]);
|
||||
} else {
|
||||
edges.set(e[2].id, [e[3]]);
|
||||
}
|
||||
});
|
||||
|
||||
const ownType = nodeType.outputs?.[index];
|
||||
|
||||
@@ -775,7 +779,7 @@ export class GraphManager extends EventEmitter<{
|
||||
|
||||
if (
|
||||
areSocketsCompatible(ownType, otherType)
|
||||
&& edges.get(node.id) !== key
|
||||
&& !edges.get(node.id)?.includes(key)
|
||||
) {
|
||||
sockets.push([node, key]);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export class GraphState {
|
||||
addMenuPosition = $state<[number, number] | null>(null);
|
||||
|
||||
snapToGrid = $state(false);
|
||||
showGrid = $state(true);
|
||||
backgroundType = $state<'grid' | 'dots' | 'none'>('grid');
|
||||
showHelp = $state(false);
|
||||
|
||||
cameraDown = [0, 0];
|
||||
@@ -186,15 +186,25 @@ export class GraphState {
|
||||
if (!node?.inputs) {
|
||||
return 5;
|
||||
}
|
||||
const height = 5
|
||||
+ 10
|
||||
* Object.keys(node.inputs).filter(
|
||||
(p) =>
|
||||
p !== 'seed'
|
||||
&& node?.inputs
|
||||
&& !(node?.inputs?.[p] !== undefined && 'setting' in node.inputs[p])
|
||||
&& node.inputs[p].hidden !== true
|
||||
).length;
|
||||
let height = 5;
|
||||
|
||||
for (const key of Object.keys(node.inputs)) {
|
||||
if (key === 'seed') continue;
|
||||
if (!node.inputs) continue;
|
||||
if (node?.inputs?.[key] === undefined) continue;
|
||||
if ('setting' in node.inputs[key]) continue;
|
||||
if (node.inputs[key].hidden) continue;
|
||||
if (
|
||||
node.inputs[key].type === 'shape'
|
||||
&& node.inputs[key].external !== true
|
||||
&& node.inputs[key].internal !== false
|
||||
) {
|
||||
height += 20;
|
||||
continue;
|
||||
}
|
||||
height += 10;
|
||||
}
|
||||
|
||||
this.nodeHeightCache[nodeTypeId] = height;
|
||||
return height;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
import { MouseEventManager } from './mouse.events';
|
||||
|
||||
const {
|
||||
keymap
|
||||
keymap,
|
||||
addMenuPadding
|
||||
}: {
|
||||
keymap: ReturnType<typeof createKeyMap>;
|
||||
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
||||
} = $props();
|
||||
|
||||
const graph = getGraphManager();
|
||||
@@ -132,8 +134,9 @@
|
||||
position={graphState.cameraPosition}
|
||||
/>
|
||||
|
||||
{#if graphState.showGrid !== false}
|
||||
{#if graphState.backgroundType !== 'none'}
|
||||
<Background
|
||||
type={graphState.backgroundType}
|
||||
cameraPosition={graphState.cameraPosition}
|
||||
{maxZoom}
|
||||
{minZoom}
|
||||
@@ -159,7 +162,13 @@
|
||||
|
||||
{#if graph.status === 'idle'}
|
||||
{#if graphState.addMenuPosition}
|
||||
<AddMenu onnode={handleNodeCreation} />
|
||||
<AddMenu
|
||||
onnode={handleNodeCreation}
|
||||
paddingTop={addMenuPadding?.top}
|
||||
paddingRight={addMenuPadding?.right}
|
||||
paddingBottom={addMenuPadding?.bottom}
|
||||
paddingLeft={addMenuPadding?.left}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if graphState.activeSocket}
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
settings?: Record<string, unknown>;
|
||||
|
||||
activeNode?: NodeInstance;
|
||||
showGrid?: boolean;
|
||||
backgroundType?: 'grid' | 'dots' | 'none';
|
||||
snapToGrid?: boolean;
|
||||
showHelp?: boolean;
|
||||
settingTypes?: Record<string, unknown>;
|
||||
|
||||
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
||||
|
||||
onsave?: (save: Graph) => void;
|
||||
onresult?: (result: unknown) => void;
|
||||
};
|
||||
@@ -25,9 +27,10 @@
|
||||
let {
|
||||
graph,
|
||||
registry,
|
||||
addMenuPadding,
|
||||
settings = $bindable(),
|
||||
activeNode = $bindable(),
|
||||
showGrid = $bindable(true),
|
||||
backgroundType = $bindable('grid'),
|
||||
snapToGrid = $bindable(true),
|
||||
showHelp = $bindable(false),
|
||||
settingTypes = $bindable(),
|
||||
@@ -43,7 +46,7 @@
|
||||
|
||||
const graphState = new GraphState(manager);
|
||||
$effect(() => {
|
||||
graphState.showGrid = showGrid;
|
||||
graphState.backgroundType = backgroundType;
|
||||
graphState.snapToGrid = snapToGrid;
|
||||
graphState.showHelp = showHelp;
|
||||
});
|
||||
@@ -83,4 +86,4 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<GraphEl {keymap} />
|
||||
<GraphEl {keymap} {addMenuPadding} />
|
||||
|
||||
@@ -9,7 +9,7 @@ const variables = [
|
||||
'outline',
|
||||
'active',
|
||||
'selected',
|
||||
'edge'
|
||||
'connection'
|
||||
] as const;
|
||||
|
||||
function getColor(variable: (typeof variables)[number]) {
|
||||
|
||||
@@ -166,15 +166,14 @@ export class MouseEventManager {
|
||||
|
||||
if (this.state.mouseDown) return;
|
||||
this.state.edgeEndPosition = null;
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (event.target instanceof HTMLElement) {
|
||||
if (
|
||||
event.target.nodeName !== 'CANVAS'
|
||||
&& !event.target.classList.contains('node')
|
||||
&& !event.target.classList.contains('content')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.nodeName !== 'CANVAS'
|
||||
&& !target.classList.contains('node')
|
||||
&& !target.classList.contains('content')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mx = event.clientX - this.state.rect.x;
|
||||
@@ -265,7 +264,7 @@ export class MouseEventManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (_socket && smallestDist < 0.9) {
|
||||
if (_socket && smallestDist < 1.5) {
|
||||
this.state.mousePosition = _socket.position;
|
||||
this.state.hoveredSocket = _socket;
|
||||
} else {
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
uniforms={{
|
||||
uColorBright: { value: colors['layer-2'] },
|
||||
uColorDark: { value: colors['layer-1'] },
|
||||
uStrokeColor: { value: colors.outline.clone() },
|
||||
uStrokeColor: { value: colors['layer-2'].clone() },
|
||||
uStrokeWidth: { value: 1.0 },
|
||||
uWidth: { value: 20 },
|
||||
uHeight: { value: height }
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
);
|
||||
const pathHover = $derived(
|
||||
createNodePath({
|
||||
depth: 8.5,
|
||||
height: 50,
|
||||
depth: 7,
|
||||
height: 40,
|
||||
y: 49,
|
||||
cornerTop,
|
||||
rightBump,
|
||||
@@ -87,8 +87,6 @@
|
||||
width: 30px;
|
||||
z-index: 100;
|
||||
border-radius: 50%;
|
||||
/* background: red; */
|
||||
/* opacity: 0.2; */
|
||||
}
|
||||
|
||||
.click-target:hover + svg path {
|
||||
@@ -108,11 +106,16 @@
|
||||
|
||||
svg path {
|
||||
stroke-width: 0.2px;
|
||||
transition: d 0.3s ease, fill 0.3s ease;
|
||||
transition:
|
||||
d 0.3s ease,
|
||||
fill 0.3s ease;
|
||||
fill: var(--color-layer-2);
|
||||
stroke: var(--stroke);
|
||||
stroke-width: var(--stroke-width);
|
||||
d: var(--path);
|
||||
|
||||
stroke-linejoin: round;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -31,11 +31,24 @@
|
||||
return 0;
|
||||
}
|
||||
|
||||
let value = $state(getDefaultValue());
|
||||
let value = $state(structuredClone($state.snapshot(getDefaultValue())));
|
||||
|
||||
function diffArray(a: number[], b?: number[] | number) {
|
||||
if (!Array.isArray(b)) return true;
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return true;
|
||||
if (a.length !== b.length) return true;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (value !== undefined && node?.props?.[id] !== value) {
|
||||
node.props = { ...node.props, [id]: value };
|
||||
const a = $state.snapshot(value);
|
||||
const b = $state.snapshot(node?.props?.[id]);
|
||||
const isDiff = Array.isArray(a) ? diffArray(a, b) : a !== b;
|
||||
if (value !== undefined && isDiff) {
|
||||
node.props = { ...node.props, [id]: a };
|
||||
if (graph) {
|
||||
graph.save();
|
||||
graph.execute();
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
const inputType = $derived(node?.state?.type?.inputs?.[id]);
|
||||
|
||||
const socketId = $derived(`${node.id}-${id}`);
|
||||
const isShape = $derived(input.type === 'shape' && input.external !== true);
|
||||
const height = $derived(isShape ? 200 : 100);
|
||||
|
||||
const graphState = getGraphState();
|
||||
const graphId = graph?.id;
|
||||
@@ -39,16 +41,6 @@
|
||||
const aspectRatio = 0.5;
|
||||
|
||||
const path = $derived(
|
||||
createNodePath({
|
||||
depth: 7,
|
||||
height: 20,
|
||||
y: 50.5,
|
||||
cornerBottom,
|
||||
leftBump,
|
||||
aspectRatio
|
||||
})
|
||||
);
|
||||
const pathDisabled = $derived(
|
||||
createNodePath({
|
||||
depth: 6,
|
||||
height: 18,
|
||||
@@ -60,8 +52,8 @@
|
||||
);
|
||||
const pathHover = $derived(
|
||||
createNodePath({
|
||||
depth: 8,
|
||||
height: 25,
|
||||
depth: 7,
|
||||
height: 20,
|
||||
y: 50.5,
|
||||
cornerBottom,
|
||||
leftBump,
|
||||
@@ -74,7 +66,8 @@
|
||||
class="wrapper"
|
||||
data-node-type={node.type}
|
||||
data-node-input={id}
|
||||
class:disabled={!graphState?.possibleSocketIds.has(socketId)}
|
||||
style:height="{height}px"
|
||||
class:possible-socket={graphState?.possibleSocketIds.has(socketId)}
|
||||
>
|
||||
{#key id && graphId}
|
||||
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
|
||||
@@ -91,10 +84,9 @@
|
||||
</div>
|
||||
|
||||
{#if node?.state?.type?.inputs?.[id]?.internal !== true}
|
||||
<div data-node-socket class="large target"></div>
|
||||
<div
|
||||
data-node-socket
|
||||
class="small target"
|
||||
class="target"
|
||||
onmousedown={handleMouseDown}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -106,13 +98,10 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
width="100"
|
||||
height="100"
|
||||
preserveAspectRatio="none"
|
||||
style={`
|
||||
--path: path("${path}");
|
||||
--hover-path: path("${pathHover}");
|
||||
--hover-path-disabled: path("${pathDisabled}");
|
||||
`}
|
||||
>
|
||||
<path vector-effect="non-scaling-stroke"></path>
|
||||
@@ -123,33 +112,26 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
transform: translateY(-0.5px);
|
||||
}
|
||||
|
||||
.target {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
/* background: red; */
|
||||
/* opacity: 0.1; */
|
||||
}
|
||||
|
||||
.small.target {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
.possible-socket .target {
|
||||
box-shadow: 0px 0px 10px rgba(255, 255, 255, 0.5);
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
z-index: -10;
|
||||
}
|
||||
|
||||
.large.target {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
cursor: unset;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.hovering-sockets) .large.target {
|
||||
pointer-events: all;
|
||||
.target:hover ~ svg path{
|
||||
d: var(--hover-path);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -179,19 +161,16 @@
|
||||
stroke: var(--stroke);
|
||||
stroke-width: var(--stroke-width);
|
||||
d: var(--path);
|
||||
}
|
||||
|
||||
:global {
|
||||
.hovering-sockets .large:hover ~ svg path {
|
||||
d: var(--hover-path);
|
||||
}
|
||||
stroke-linejoin: round;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.content.disabled {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.disabled svg path {
|
||||
d: var(--hover-path-disabled) !important;
|
||||
.possible-socket svg path {
|
||||
d: var(--hover-path);
|
||||
}
|
||||
</style>
|
||||
|
||||
86
app/src/lib/graph-interface/test-utils.ts
Normal file
86
app/src/lib/graph-interface/test-utils.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types';
|
||||
|
||||
export function createMockNodeRegistry(nodes: NodeDefinition[]): NodeRegistry {
|
||||
const nodesMap = new Map(nodes.map(n => [n.id, n]));
|
||||
return {
|
||||
status: 'ready' as const,
|
||||
load: async (nodeIds: NodeId[]) => {
|
||||
const loaded: NodeDefinition[] = [];
|
||||
for (const id of nodeIds) {
|
||||
if (nodesMap.has(id)) {
|
||||
loaded.push(nodesMap.get(id)!);
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
},
|
||||
getNode: (id: string) => nodesMap.get(id as NodeId),
|
||||
getAllNodes: () => Array.from(nodesMap.values()),
|
||||
register: async () => {
|
||||
throw new Error('Not implemented in mock');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const mockFloatOutputNode: NodeDefinition = {
|
||||
id: 'test/node/output',
|
||||
inputs: {},
|
||||
outputs: ['float'],
|
||||
meta: { title: 'Float Output' },
|
||||
execute: () => new Int32Array()
|
||||
};
|
||||
|
||||
export const mockFloatInputNode: NodeDefinition = {
|
||||
id: 'test/node/input',
|
||||
inputs: { value: { type: 'float' } },
|
||||
outputs: [],
|
||||
meta: { title: 'Float Input' },
|
||||
execute: () => new Int32Array()
|
||||
};
|
||||
|
||||
export const mockGeometryOutputNode: NodeDefinition = {
|
||||
id: 'test/node/geometry',
|
||||
inputs: {},
|
||||
outputs: ['geometry'],
|
||||
meta: { title: 'Geometry Output' },
|
||||
execute: () => new Int32Array()
|
||||
};
|
||||
|
||||
export const mockPathInputNode: NodeDefinition = {
|
||||
id: 'test/node/path',
|
||||
inputs: { input: { type: 'path', accepts: ['geometry'] } },
|
||||
outputs: [],
|
||||
meta: { title: 'Path Input' },
|
||||
execute: () => new Int32Array()
|
||||
};
|
||||
|
||||
export const mockVec3OutputNode: NodeDefinition = {
|
||||
id: 'test/node/vec3',
|
||||
inputs: {},
|
||||
outputs: ['vec3'],
|
||||
meta: { title: 'Vec3 Output' },
|
||||
execute: () => new Int32Array()
|
||||
};
|
||||
|
||||
export const mockIntegerInputNode: NodeDefinition = {
|
||||
id: 'test/node/integer',
|
||||
inputs: { value: { type: 'integer' } },
|
||||
outputs: [],
|
||||
meta: { title: 'Integer Input' },
|
||||
execute: () => new Int32Array()
|
||||
};
|
||||
|
||||
export const mockBooleanOutputNode: NodeDefinition = {
|
||||
id: 'test/node/boolean',
|
||||
inputs: {},
|
||||
outputs: ['boolean'],
|
||||
meta: { title: 'Boolean Output' },
|
||||
execute: () => new Int32Array()
|
||||
};
|
||||
|
||||
export const mockBooleanInputNode: NodeDefinition = {
|
||||
id: 'test/node/boolean-input',
|
||||
inputs: { value: { type: 'boolean' } },
|
||||
outputs: [],
|
||||
meta: { title: 'Boolean Input' },
|
||||
execute: () => new Int32Array()
|
||||
};
|
||||
110
app/src/lib/graph-templates.test.ts
Normal file
110
app/src/lib/graph-templates.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { grid } from '$lib/graph-templates/grid';
|
||||
import { tree } from '$lib/graph-templates/tree';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('graph-templates', () => {
|
||||
describe('grid', () => {
|
||||
it('should create a grid graph with nodes and edges', () => {
|
||||
const result = grid(2, 3);
|
||||
expect(result.nodes.length).toBeGreaterThan(0);
|
||||
expect(result.edges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have output node at the end', () => {
|
||||
const result = grid(1, 1);
|
||||
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||
expect(outputNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create nodes based on grid dimensions', () => {
|
||||
const result = grid(2, 2);
|
||||
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
|
||||
expect(mathNodes.length).toBeGreaterThan(0);
|
||||
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||
expect(outputNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have output node at the end', () => {
|
||||
const result = grid(1, 1);
|
||||
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||
expect(outputNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create nodes based on grid dimensions', () => {
|
||||
const result = grid(2, 2);
|
||||
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
|
||||
expect(mathNodes.length).toBeGreaterThan(0);
|
||||
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||
expect(outputNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have valid node positions', () => {
|
||||
const result = grid(3, 2);
|
||||
|
||||
result.nodes.forEach(node => {
|
||||
expect(node.position).toHaveLength(2);
|
||||
expect(typeof node.position[0]).toBe('number');
|
||||
expect(typeof node.position[1]).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate valid graph structure', () => {
|
||||
const result = grid(2, 2);
|
||||
|
||||
result.nodes.forEach(node => {
|
||||
expect(typeof node.id).toBe('number');
|
||||
expect(node.type).toBeTruthy();
|
||||
});
|
||||
|
||||
result.edges.forEach(edge => {
|
||||
expect(edge).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tree', () => {
|
||||
it('should create a tree graph with specified depth', () => {
|
||||
const result = tree(0);
|
||||
|
||||
expect(result.nodes.length).toBeGreaterThan(0);
|
||||
expect(result.edges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have root output node', () => {
|
||||
const result = tree(2);
|
||||
|
||||
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||
expect(outputNode).toBeDefined();
|
||||
expect(outputNode?.id).toBe(0);
|
||||
});
|
||||
|
||||
it('should increase node count with depth', () => {
|
||||
const tree0 = tree(0);
|
||||
const tree1 = tree(1);
|
||||
const tree2 = tree(2);
|
||||
|
||||
expect(tree0.nodes.length).toBeLessThan(tree1.nodes.length);
|
||||
expect(tree1.nodes.length).toBeLessThan(tree2.nodes.length);
|
||||
});
|
||||
|
||||
it('should create binary tree structure', () => {
|
||||
const result = tree(2);
|
||||
|
||||
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
|
||||
expect(mathNodes.length).toBeGreaterThan(0);
|
||||
|
||||
const edgeCount = result.edges.length;
|
||||
expect(edgeCount).toBe(result.nodes.length - 1);
|
||||
});
|
||||
|
||||
it('should have valid node positions', () => {
|
||||
const result = tree(3);
|
||||
|
||||
result.nodes.forEach(node => {
|
||||
expect(node.position).toHaveLength(2);
|
||||
expect(typeof node.position[0]).toBe('number');
|
||||
expect(typeof node.position[1]).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,4 +4,5 @@ export { default as lottaFaces } from './lotta-faces.json';
|
||||
export { default as lottaNodesAndFaces } from './lotta-nodes-and-faces.json';
|
||||
export { default as lottaNodes } from './lotta-nodes.json';
|
||||
export { plant } from './plant';
|
||||
export { default as simple } from './simple.json';
|
||||
export { tree } from './tree';
|
||||
|
||||
63
app/src/lib/graph-templates/simple.json
Normal file
63
app/src/lib/graph-templates/simple.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"id": 0,
|
||||
"settings": {
|
||||
"resolution.circle": 54,
|
||||
"resolution.curve": 20,
|
||||
"randomSeed": true
|
||||
},
|
||||
"meta": {
|
||||
"title": "New Project",
|
||||
"lastModified": "2026-02-03T16:56:40.375Z"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"position": [
|
||||
215,
|
||||
85
|
||||
],
|
||||
"type": "max/plantarium/output",
|
||||
"props": {}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"position": [
|
||||
165,
|
||||
72.5
|
||||
],
|
||||
"type": "max/plantarium/stem",
|
||||
"props": {
|
||||
"amount": 50,
|
||||
"length": 4,
|
||||
"thickness": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"position": [
|
||||
190,
|
||||
77.5
|
||||
],
|
||||
"type": "max/plantarium/noise",
|
||||
"props": {
|
||||
"plant": 0,
|
||||
"scale": 0.5,
|
||||
"strength": 5
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
10,
|
||||
0,
|
||||
11,
|
||||
"plant"
|
||||
],
|
||||
[
|
||||
11,
|
||||
0,
|
||||
9,
|
||||
"input"
|
||||
]
|
||||
]
|
||||
}
|
||||
145
app/src/lib/helpers.test.ts
Normal file
145
app/src/lib/helpers.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { clone, debounce, humanizeDuration, humanizeNumber, lerp, snapToGrid } from '$lib/helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('snapToGrid', () => {
|
||||
it('should snap to nearest grid point', () => {
|
||||
expect(snapToGrid(5, 10)).toBe(10);
|
||||
expect(snapToGrid(15, 10)).toBe(20);
|
||||
expect(snapToGrid(0, 10)).toBe(0);
|
||||
expect(snapToGrid(-10, 10)).toBe(-10);
|
||||
});
|
||||
|
||||
it('should snap exact midpoint values', () => {
|
||||
expect(snapToGrid(5, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('should use default grid size of 10', () => {
|
||||
expect(snapToGrid(5)).toBe(10);
|
||||
expect(snapToGrid(15)).toBe(20);
|
||||
});
|
||||
|
||||
it('should handle values exactly on grid', () => {
|
||||
expect(snapToGrid(10, 10)).toBe(10);
|
||||
expect(snapToGrid(20, 10)).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lerp', () => {
|
||||
it('should linearly interpolate between two values', () => {
|
||||
expect(lerp(0, 100, 0)).toBe(0);
|
||||
expect(lerp(0, 100, 0.5)).toBe(50);
|
||||
expect(lerp(0, 100, 1)).toBe(100);
|
||||
});
|
||||
|
||||
it('should handle negative values', () => {
|
||||
expect(lerp(-50, 50, 0.5)).toBe(0);
|
||||
expect(lerp(-100, 0, 0.5)).toBe(-50);
|
||||
});
|
||||
|
||||
it('should handle t values outside 0-1 range', () => {
|
||||
expect(lerp(0, 100, -0.5)).toBe(-50);
|
||||
expect(lerp(0, 100, 1.5)).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('humanizeNumber', () => {
|
||||
it('should return unchanged numbers below 1000', () => {
|
||||
expect(humanizeNumber(0)).toBe('0');
|
||||
expect(humanizeNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should add K suffix for thousands', () => {
|
||||
expect(humanizeNumber(1000)).toBe('1K');
|
||||
expect(humanizeNumber(1500)).toBe('1.5K');
|
||||
expect(humanizeNumber(999999)).toBe('1000K');
|
||||
});
|
||||
|
||||
it('should add M suffix for millions', () => {
|
||||
expect(humanizeNumber(1000000)).toBe('1M');
|
||||
expect(humanizeNumber(2500000)).toBe('2.5M');
|
||||
});
|
||||
|
||||
it('should add B suffix for billions', () => {
|
||||
expect(humanizeNumber(1000000000)).toBe('1B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('humanizeDuration', () => {
|
||||
it('should return ms for very short durations', () => {
|
||||
expect(humanizeDuration(100)).toBe('100ms');
|
||||
expect(humanizeDuration(999)).toBe('999ms');
|
||||
});
|
||||
|
||||
it('should format seconds', () => {
|
||||
expect(humanizeDuration(1000)).toBe('1s');
|
||||
expect(humanizeDuration(1500)).toBe('1s500ms');
|
||||
expect(humanizeDuration(59000)).toBe('59s');
|
||||
});
|
||||
|
||||
it('should format minutes', () => {
|
||||
expect(humanizeDuration(60000)).toBe('1m');
|
||||
expect(humanizeDuration(90000)).toBe('1m 30s');
|
||||
});
|
||||
|
||||
it('should format hours', () => {
|
||||
expect(humanizeDuration(3600000)).toBe('1h');
|
||||
expect(humanizeDuration(3661000)).toBe('1h 1m 1s');
|
||||
});
|
||||
|
||||
it('should format days', () => {
|
||||
expect(humanizeDuration(86400000)).toBe('1d');
|
||||
expect(humanizeDuration(90061000)).toBe('1d 1h 1m 1s');
|
||||
});
|
||||
|
||||
it('should handle zero', () => {
|
||||
expect(humanizeDuration(0)).toBe('0ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
it('should return a function', () => {
|
||||
const fn = debounce(() => {}, 100);
|
||||
expect(typeof fn).toBe('function');
|
||||
});
|
||||
|
||||
it('should only call once when invoked multiple times within delay', () => {
|
||||
let callCount = 0;
|
||||
const fn = debounce(() => {
|
||||
callCount++;
|
||||
}, 100);
|
||||
fn();
|
||||
const firstCall = callCount;
|
||||
fn();
|
||||
fn();
|
||||
expect(callCount).toBe(firstCall);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clone', () => {
|
||||
it('should deep clone objects', () => {
|
||||
const original = { a: 1, b: { c: 2 } };
|
||||
const cloned = clone(original);
|
||||
|
||||
expect(cloned).toEqual(original);
|
||||
expect(cloned).not.toBe(original);
|
||||
expect(cloned.b).not.toBe(original.b);
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const original = [1, 2, [3, 4]];
|
||||
const cloned = clone(original);
|
||||
|
||||
expect(cloned).toEqual(original);
|
||||
expect(cloned).not.toBe(original);
|
||||
expect(cloned[2]).not.toBe(original[2]);
|
||||
});
|
||||
|
||||
it('should handle primitives', () => {
|
||||
expect(clone(42)).toBe(42);
|
||||
expect(clone('hello')).toBe('hello');
|
||||
expect(clone(true)).toBe(true);
|
||||
expect(clone(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
app/src/lib/helpers/deepMerge.test.ts
Normal file
72
app/src/lib/helpers/deepMerge.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { isObject, mergeDeep } from '$lib/helpers/deepMerge';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('deepMerge', () => {
|
||||
describe('isObject', () => {
|
||||
it('should return true for plain objects', () => {
|
||||
expect(isObject({})).toBe(true);
|
||||
expect(isObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-objects', () => {
|
||||
expect(isObject([])).toBe(false);
|
||||
expect(isObject('string')).toBe(false);
|
||||
expect(isObject(42)).toBe(false);
|
||||
expect(isObject(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeDeep', () => {
|
||||
it('should merge two flat objects', () => {
|
||||
const target = { a: 1, b: 2 };
|
||||
const source = { b: 3, c: 4 };
|
||||
const result = mergeDeep(target, source);
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 3, c: 4 });
|
||||
});
|
||||
|
||||
it('should deeply merge nested objects', () => {
|
||||
const target = { a: { x: 1 }, b: { y: 2 } };
|
||||
const source = { a: { y: 2 }, c: { z: 3 } };
|
||||
const result = mergeDeep(target, source);
|
||||
|
||||
expect(result).toEqual({
|
||||
a: { x: 1, y: 2 },
|
||||
b: { y: 2 },
|
||||
c: { z: 3 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple sources', () => {
|
||||
const target = { a: 1 };
|
||||
const source1 = { b: 2 };
|
||||
const source2 = { c: 3 };
|
||||
const result = mergeDeep(target, source1, source2);
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 2, c: 3 });
|
||||
});
|
||||
|
||||
it('should return target if no sources provided', () => {
|
||||
const target = { a: 1 };
|
||||
const result = mergeDeep(target);
|
||||
|
||||
expect(result).toBe(target);
|
||||
});
|
||||
|
||||
it('should overwrite non-object values', () => {
|
||||
const target = { a: { b: 1 } };
|
||||
const source = { a: 'string' };
|
||||
const result = mergeDeep(target, source);
|
||||
|
||||
expect(result.a).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle arrays by replacing', () => {
|
||||
const target = { a: [1, 2] };
|
||||
const source = { a: [3, 4] };
|
||||
const result = mergeDeep(target, source);
|
||||
|
||||
expect(result.a).toEqual([3, 4]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,7 @@
|
||||
position: absolute;
|
||||
}
|
||||
svg {
|
||||
height: 124px;
|
||||
height: 126px;
|
||||
margin: 24px 0px;
|
||||
border-top: solid thin var(--color-outline);
|
||||
border-bottom: solid thin var(--color-outline);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { defaultPlant, lottaFaces, plant } from '$lib/graph-templates';
|
||||
import { defaultPlant, lottaFaces, plant, simple } from '$lib/graph-templates';
|
||||
import type { Graph } from '$lib/types';
|
||||
import { InputSelect } from '@nodarium/ui';
|
||||
import type { ProjectManager } from './project-manager.svelte';
|
||||
|
||||
const { projectManager } = $props<{ projectManager: ProjectManager }>();
|
||||
|
||||
let showNewProject = $state(false);
|
||||
let newProjectName = $state('');
|
||||
let selectedTemplate = $state('defaultPlant');
|
||||
|
||||
const templates = [
|
||||
{
|
||||
@@ -16,25 +16,27 @@
|
||||
graph: defaultPlant as unknown as Graph
|
||||
},
|
||||
{ name: 'Plant', value: 'plant', graph: plant as unknown as Graph },
|
||||
{ name: 'Simple', value: 'simple', graph: simple as unknown as Graph },
|
||||
{
|
||||
name: 'Lotta Faces',
|
||||
value: 'lottaFaces',
|
||||
graph: lottaFaces as unknown as Graph
|
||||
}
|
||||
];
|
||||
let selectedTemplateIndex = $state(0);
|
||||
|
||||
function handleCreate() {
|
||||
const template = templates.find((t) => t.value === selectedTemplate) || templates[0];
|
||||
const template = templates[selectedTemplateIndex] || templates[0];
|
||||
projectManager.handleCreateProject(template.graph, newProjectName);
|
||||
newProjectName = '';
|
||||
showNewProject = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center">
|
||||
<header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center bg-layer-2">
|
||||
<h3>Project</h3>
|
||||
<button
|
||||
class="px-3 py-1 bg-layer-0 rounded"
|
||||
class="px-3 py-1 bg-layer-1 rounded"
|
||||
onclick={() => (showNewProject = !showNewProject)}
|
||||
>
|
||||
New
|
||||
@@ -42,24 +44,17 @@
|
||||
</header>
|
||||
|
||||
{#if showNewProject}
|
||||
<div class="flex flex-col px-4 py-3 border-b-1 border-outline gap-2">
|
||||
<div class="flex flex-col px-4 py-3.5 mt-[1px] border-b-1 border-outline gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newProjectName}
|
||||
placeholder="Project name"
|
||||
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
|
||||
class="w-full px-2 py-2 bg-layer-2 rounded"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<select
|
||||
bind:value={selectedTemplate}
|
||||
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
|
||||
>
|
||||
{#each templates as template (template.name)}
|
||||
<option value={template.value}>{template.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<InputSelect options={templates.map(t => t.name)} bind:value={selectedTemplateIndex} />
|
||||
<button
|
||||
class="cursor-pointer self-end px-3 py-1 bg-blue-600 rounded"
|
||||
class="cursor-pointer self-end px-3 py-1 bg-selected rounded"
|
||||
onclick={() => handleCreate()}
|
||||
>
|
||||
Create
|
||||
@@ -67,20 +62,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4 text-white min-h-screen">
|
||||
<div class="text-white min-h-screen">
|
||||
{#if projectManager.loading}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
|
||||
<ul class="space-y-2">
|
||||
<ul>
|
||||
{#each projectManager.projects as project (project.id)}
|
||||
<li>
|
||||
<div
|
||||
class="
|
||||
w-full text-left px-3 py-2 rounded cursor-pointer {projectManager
|
||||
h-[70px] border-b-1 border-b-outline
|
||||
flex
|
||||
w-full text-left px-3 py-2 cursor-pointer {projectManager
|
||||
.activeProjectId.value === project.id
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-800 hover:bg-gray-700'}
|
||||
? 'border-l-2 border-l-selected pl-2.5!'
|
||||
: ''}
|
||||
"
|
||||
onclick={() => projectManager.handleSelectProject(project.id!)}
|
||||
role="button"
|
||||
@@ -89,10 +86,10 @@
|
||||
e.key === 'Enter'
|
||||
&& projectManager.handleSelectProject(project.id!)}
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center grow">
|
||||
<span>{project.meta?.title || 'Untitled'}</span>
|
||||
<button
|
||||
class="text-red-400 hover:text-red-300"
|
||||
class="text-layer-1! bg-red-500 w-7 text-xl rounded-sm cursor-pointer opacity-20 hover:opacity-80"
|
||||
onclick={() => {
|
||||
projectManager.handleDeleteProject(project.id!);
|
||||
}}
|
||||
|
||||
@@ -54,7 +54,7 @@ export class ProjectManager {
|
||||
|
||||
g.id = id;
|
||||
if (!g.meta) g.meta = {};
|
||||
if (!g.meta.title) g.meta.title = title;
|
||||
g.meta.title = title;
|
||||
|
||||
db.saveGraph(g);
|
||||
this.projects = [...this.projects, g];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { decodeFloat, splitNestedArray } from '@nodarium/utils';
|
||||
import type { PerformanceStore } from '@nodarium/utils';
|
||||
import { Canvas } from '@threlte/core';
|
||||
import { Vector3 } from 'three';
|
||||
import { DoubleSide, Vector3 } from 'three';
|
||||
import { type Group, MeshMatcapMaterial, TextureLoader } from 'three';
|
||||
import { createGeometryPool, createInstancedGeometryPool } from './geometryPool';
|
||||
import Scene from './Scene.svelte';
|
||||
@@ -14,7 +14,8 @@
|
||||
matcap.colorSpace = 'srgb';
|
||||
const material = new MeshMatcapMaterial({
|
||||
color: 0xffffff,
|
||||
matcap
|
||||
matcap,
|
||||
side: DoubleSide
|
||||
});
|
||||
|
||||
let sceneComponent = $state<ReturnType<typeof Scene>>();
|
||||
|
||||
@@ -28,7 +28,7 @@ function getValue(input: NodeInput, value?: unknown) {
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (input.type === 'vec3') {
|
||||
if (input.type === 'vec3' || input.type === 'shape') {
|
||||
return [
|
||||
0,
|
||||
value.length + 1,
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (Array.isArray(inputValue) && node.type === 'vec3') {
|
||||
return inputValue;
|
||||
}
|
||||
|
||||
// If the component is supplied with a default value use that
|
||||
if (inputValue !== undefined && typeof inputValue !== 'object') {
|
||||
return inputValue;
|
||||
@@ -98,7 +102,7 @@
|
||||
&& typeof internalValue === 'number'
|
||||
) {
|
||||
value[key] = node?.options?.[internalValue];
|
||||
} else if (internalValue) {
|
||||
} else if (internalValue !== undefined) {
|
||||
value[key] = internalValue;
|
||||
}
|
||||
});
|
||||
@@ -124,7 +128,6 @@
|
||||
|
||||
{#if key && isNodeInput(type?.[key])}
|
||||
{@const inputType = type[key]}
|
||||
<!-- Leaf input -->
|
||||
<div class="input input-{inputType.type}" class:first-level={depth === 1}>
|
||||
{#if inputType.type === 'button'}
|
||||
<button onclick={handleClick}>
|
||||
@@ -138,7 +141,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if depth === 0}
|
||||
<!-- Root: iterate over top-level keys -->
|
||||
{#each Object.keys(type ?? {}).filter((k) => k !== 'title') as childKey (childKey)}
|
||||
<NestedSettings
|
||||
id={`${id}.${childKey}`}
|
||||
@@ -150,7 +152,6 @@
|
||||
{/each}
|
||||
<hr />
|
||||
{:else if key && type?.[key]}
|
||||
<!-- Group -->
|
||||
{#if depth > 0}
|
||||
<hr />
|
||||
{/if}
|
||||
@@ -210,7 +211,7 @@
|
||||
.first-level.input {
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
padding-bottom: 1px;
|
||||
padding-bottom: 0.5px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const themes = [
|
||||
'catppuccin',
|
||||
'solarized',
|
||||
'high-contrast',
|
||||
'high-contrast-light',
|
||||
'nord',
|
||||
'dracula'
|
||||
] as const;
|
||||
@@ -29,10 +30,11 @@ export const AppSettingTypes = {
|
||||
},
|
||||
nodeInterface: {
|
||||
title: 'Node Interface',
|
||||
showNodeGrid: {
|
||||
type: 'boolean',
|
||||
label: 'Show Grid',
|
||||
value: true
|
||||
backgroundType: {
|
||||
type: 'select',
|
||||
label: 'Background',
|
||||
options: ['grid', 'dots', 'none'],
|
||||
value: 'grid'
|
||||
},
|
||||
snapToGrid: {
|
||||
type: 'boolean',
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="wrapper" class:hidden>
|
||||
{#if title}
|
||||
<header class="bg-layer-2">
|
||||
<h3 class="font-bold">{title}</h3>
|
||||
<h3>{title}</h3>
|
||||
</header>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import { type Snippet } from 'svelte';
|
||||
import { panelState as state } from './PanelState.svelte';
|
||||
|
||||
const { children } = $props<{ children?: Snippet }>();
|
||||
let { children, open = $bindable(false) } = $props<{ children?: Snippet; open?: boolean }>();
|
||||
|
||||
$effect(() => {
|
||||
open = !!state.activePanel.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="wrapper" class:visible={state.activePanel.value}>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</script>
|
||||
|
||||
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
||||
<h3 class="font-bold">Node Settings</h3>
|
||||
<h3>Node Settings</h3>
|
||||
</div>
|
||||
|
||||
{#if node}
|
||||
|
||||
185
app/src/lib/sidebar/panels/Changelog.svelte
Normal file
185
app/src/lib/sidebar/panels/Changelog.svelte
Normal file
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { Details } from '@nodarium/ui';
|
||||
import { micromark } from 'micromark';
|
||||
|
||||
type Props = {
|
||||
git?: Record<string, string>;
|
||||
changelog?: string;
|
||||
};
|
||||
|
||||
const {
|
||||
git,
|
||||
changelog
|
||||
}: Props = $props();
|
||||
|
||||
const typeMap = new Map([
|
||||
['fix', 'border-l-red-800'],
|
||||
['feat', 'border-l-green-800'],
|
||||
['chore', 'border-l-gray-800'],
|
||||
['docs', 'border-l-blue-800'],
|
||||
['refactor', 'border-l-purple-800'],
|
||||
['ci', 'border-l-red-400']
|
||||
]);
|
||||
|
||||
function detectCommitType(commit: string) {
|
||||
for (const key of typeMap.keys()) {
|
||||
if (commit.startsWith(key)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseCommit(line?: string) {
|
||||
if (!line) return;
|
||||
|
||||
const regex = /^\s*-\s*\[([a-f0-9]+)\]\((https?:\/\/[^\s)]+)\)\s+(.+)$/;
|
||||
|
||||
const match = line.match(regex);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, sha, link, description] = match;
|
||||
|
||||
return {
|
||||
sha,
|
||||
link,
|
||||
description,
|
||||
type: detectCommitType(description)
|
||||
};
|
||||
}
|
||||
|
||||
function parseChangelog(md: string) {
|
||||
return md.split(/^# v/gm)
|
||||
.filter(l => !!l.length)
|
||||
.map(release => {
|
||||
const [firstLine, ...rest] = release.split('\n');
|
||||
const title = firstLine.trim();
|
||||
|
||||
const blocks = rest
|
||||
.join('\n')
|
||||
.split('---');
|
||||
|
||||
const commits = blocks.length > 1
|
||||
? blocks
|
||||
.at(-1)
|
||||
?.split('\n')
|
||||
?.map(line => parseCommit(line))
|
||||
?.filter(c => !!c)
|
||||
: [];
|
||||
|
||||
const description = (
|
||||
blocks.length > 1
|
||||
? blocks
|
||||
.slice(0, -1)
|
||||
.join('\n')
|
||||
: blocks[0]
|
||||
).trim();
|
||||
|
||||
return {
|
||||
description: micromark(description),
|
||||
title,
|
||||
commits
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="changelog" class="p-4 font-mono text-text overflow-y-auto max-h-full space-y-5">
|
||||
{#if git}
|
||||
<div class="mb-4 p-3 bg-layer-2 text-xs rounded">
|
||||
<p><strong>Branch:</strong> {git.branch}</p>
|
||||
<p>
|
||||
<strong>Commit:</strong>
|
||||
<a
|
||||
href="https://git.max-richter.dev/max/nodarium/commit/{git.sha}"
|
||||
class="link"
|
||||
target="_blank"
|
||||
>
|
||||
{git.sha.slice(0, 7)}
|
||||
</a>
|
||||
– {git.commit_message}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Commits since last release:</strong>
|
||||
{git.commits_since_last_release}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Timestamp:</strong>
|
||||
{new Date(git.commit_timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if changelog}
|
||||
{#each parseChangelog(changelog) as release (release)}
|
||||
<Details title={release.title}>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div id="description" class="pb-5">{@html release.description}</div>
|
||||
|
||||
{#if release?.commits?.length}
|
||||
<Details
|
||||
title="All Commits"
|
||||
class="commits"
|
||||
>
|
||||
{#each release.commits as commit (commit)}
|
||||
<p class="py-1 leading-7 text-xs border-b-1 border-l-1 border-b-outline last:border-b-0 -ml-2 pl-2 {typeMap.get(commit.type)}">
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={commit.link} class="link" target="_blank">{commit.sha}</a>
|
||||
{commit.description}
|
||||
</p>
|
||||
{/each}
|
||||
</Details>
|
||||
{/if}
|
||||
</Details>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
@reference "tailwindcss";
|
||||
|
||||
#changelog :global(.commits) {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
border-radius: 0px 0px 2px 2px !important;
|
||||
}
|
||||
|
||||
#changelog :global(details > div){
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
#changelog :global(.commits > div) {
|
||||
padding-bottom: 0px;
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
#description :global(h2) {
|
||||
@apply font-bold mt-4 mb-1;
|
||||
}
|
||||
#description :global(h2:first-child) {
|
||||
margin-top: 0px !important;
|
||||
}
|
||||
|
||||
#description :global(ul) {
|
||||
padding-left: 1em;
|
||||
}
|
||||
#description :global(li),
|
||||
#description :global(p) {
|
||||
@apply text-xs!;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
#changelog :global(details > details[open] > summary){
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #60a5fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
return JSON.stringify(
|
||||
{
|
||||
...g,
|
||||
nodes: g.nodes.map((n: object) => ({ ...n, tmp: undefined }))
|
||||
nodes: g.nodes.map((n: object) => ({ ...n, tmp: undefined, state: undefined }))
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
20
app/src/routes/+error.svelte
Normal file
20
app/src/routes/+error.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<main class="w-screen h-screen flex flex-col items-center justify-center">
|
||||
<div class="outline-1 outline-outline bg-layer-2">
|
||||
<h1 class="p-8 text-3xl">@nodarium/error</h1>
|
||||
<hr>
|
||||
<pre class="p-8">{JSON.stringify(page.error, null, 2)}</pre>
|
||||
<hr>
|
||||
<div class="flex p-4">
|
||||
<button
|
||||
class="bg-layer-2 outline-1 outline-outline p-3 px-6 rounded-sm cursor-pointer"
|
||||
on:click={() => window.location.reload()}
|
||||
>
|
||||
reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1 +1,28 @@
|
||||
export const prerender = true;
|
||||
|
||||
export async function load({ fetch }) {
|
||||
async function fetchChangelog() {
|
||||
try {
|
||||
const res = await fetch('/CHANGELOG.md');
|
||||
return await res.text();
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch CHANGELOG.md', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitInfo() {
|
||||
try {
|
||||
const res = await fetch('/git.json');
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch git.json', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
git: await fetchGitInfo(),
|
||||
changelog: await fetchChangelog()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Panel from '$lib/sidebar/Panel.svelte';
|
||||
import ActiveNodeSettings from '$lib/sidebar/panels/ActiveNodeSettings.svelte';
|
||||
import BenchmarkPanel from '$lib/sidebar/panels/BenchmarkPanel.svelte';
|
||||
import Changelog from '$lib/sidebar/panels/Changelog.svelte';
|
||||
import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte';
|
||||
import GraphSource from '$lib/sidebar/panels/GraphSource.svelte';
|
||||
import Keymap from '$lib/sidebar/panels/Keymap.svelte';
|
||||
@@ -28,6 +29,8 @@
|
||||
|
||||
let performanceStore = createPerformanceStore();
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const registryCache = new IndexDBCache('node-registry');
|
||||
const nodeRegistry = new RemoteNodeRegistry('', registryCache);
|
||||
const workerRuntime = new WorkerRuntimeExecutor();
|
||||
@@ -60,6 +63,7 @@
|
||||
let activeNode = $state<NodeInstance | undefined>(undefined);
|
||||
let scene = $state<Group>(null!);
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!);
|
||||
let viewerComponent = $state<ReturnType<typeof Viewer>>();
|
||||
const manager = $derived(graphInterface?.manager);
|
||||
@@ -168,7 +172,8 @@
|
||||
graph={pm.graph}
|
||||
bind:this={graphInterface}
|
||||
registry={nodeRegistry}
|
||||
showGrid={appSettings.value.nodeInterface.showNodeGrid}
|
||||
addMenuPadding={{ right: sidebarOpen ? 330 : undefined }}
|
||||
backgroundType={appSettings.value.nodeInterface.backgroundType}
|
||||
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
|
||||
bind:activeNode
|
||||
bind:showHelp={appSettings.value.nodeInterface.showHelp}
|
||||
@@ -178,7 +183,7 @@
|
||||
onresult={(result) => handleUpdate(result as Graph)}
|
||||
/>
|
||||
{/if}
|
||||
<Sidebar>
|
||||
<Sidebar bind:open={sidebarOpen}>
|
||||
<Panel id="general" title="General" icon="i-[tabler--settings]">
|
||||
<NestedSettings
|
||||
id="general"
|
||||
@@ -249,6 +254,13 @@
|
||||
/>
|
||||
<ActiveNodeSettings {manager} bind:node={activeNode} />
|
||||
</Panel>
|
||||
<Panel
|
||||
id="changelog"
|
||||
title="Changelog"
|
||||
icon="i-[tabler--file-text-spark] bg-green-400"
|
||||
>
|
||||
<Changelog git={data.git} changelog={data.changelog} />
|
||||
</Panel>
|
||||
</Sidebar>
|
||||
</Grid.Cell>
|
||||
</Grid.Row>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import Sidebar from '$lib/sidebar/Sidebar.svelte';
|
||||
import { type NodeId, type NodeInstance } from '@nodarium/types';
|
||||
import { concatEncodedArrays, createWasmWrapper, encodeNestedArray } from '@nodarium/utils';
|
||||
import Code from './Code.svelte';
|
||||
|
||||
const registryCache = new IndexDBCache('node-registry');
|
||||
const nodeRegistry = new RemoteNodeRegistry('', registryCache);
|
||||
@@ -78,11 +77,7 @@
|
||||
</Grid.Cell>
|
||||
|
||||
<Grid.Cell>
|
||||
<div class="h-screen w-[80vw] overflow-y-auto">
|
||||
{#if nodeWasm}
|
||||
<Code wasm={nodeWasm} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="h-screen w-[80vw] overflow-y-auto"></div>
|
||||
</Grid.Cell>
|
||||
</Grid.Row>
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import wabtInit from 'wabt';
|
||||
|
||||
const { wasm } = $props<{ wasm: ArrayBuffer }>();
|
||||
|
||||
async function toWat(arrayBuffer: ArrayBuffer) {
|
||||
const wabt = await wabtInit();
|
||||
|
||||
const module = wabt.readWasm(new Uint8Array(arrayBuffer), {
|
||||
readDebugNames: true
|
||||
});
|
||||
|
||||
module.generateNames();
|
||||
module.applyNames();
|
||||
|
||||
return module.toText({ foldExprs: false, inlineExport: false });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await toWat(wasm)}
|
||||
<p>Converting to WAT</p>
|
||||
{:then c}
|
||||
<pre>
|
||||
<code class="text-gray-50">{c}</code>
|
||||
</pre>
|
||||
{/await}
|
||||
2
app/static/.gitignore
vendored
2
app/static/.gitignore
vendored
@@ -1 +1,3 @@
|
||||
nodes/
|
||||
CHANGELOG.md
|
||||
git.json
|
||||
|
||||
19
app/static/favicon.svg
Normal file
19
app/static/favicon.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M55.2154 77.6602L52.2788 78.3944L49.2607 60.6933L52.2788 33.0404L47.6293 18.0312L45.9162 18.6838L41.6745 28.7987L31.6412 33.4483H20.2211L21.6894 24.0675L31.6412 15.9919L45.9162 15.1762L49.7501 15.9919L55.2154 32.6326L54.5628 38.5873L64.8409 33.0404L69.5721 37.69L80.1764 43.1553L84.1734 52.8624L83.113 64.1193L73.8954 61.8353L66.3092 52.4545V38.5873L64.1068 36.7112L54.155 42.4212L52.2788 60.6933L55.2154 77.6602Z"
|
||||
fill="url(#paint0_linear)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="34.3903"
|
||||
y1="15.1762"
|
||||
x2="52.1972"
|
||||
y2="78.3944"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#4CAF7B" />
|
||||
<stop offset="1" stop-color="#347452" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 861 B |
@@ -1,19 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--logos"
|
||||
width="26.6"
|
||||
height="32"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 256 308"
|
||||
>
|
||||
<path
|
||||
fill="#FF3E00"
|
||||
d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"
|
||||
></path><path
|
||||
fill="#FFF"
|
||||
d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"
|
||||
></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,26 +0,0 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z"
|
||||
fill="#FFC131"
|
||||
/>
|
||||
<ellipse
|
||||
cx="84.1426"
|
||||
cy="147"
|
||||
rx="22"
|
||||
ry="22"
|
||||
transform="rotate(180 84.1426 147)"
|
||||
fill="#24C8DB"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z"
|
||||
fill="#FFC131"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z"
|
||||
fill="#24C8DB"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,37 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--logos"
|
||||
width="31.88"
|
||||
height="32"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 256 257"
|
||||
>
|
||||
<defs><linearGradient
|
||||
id="IconifyId1813088fe1fbc01fb466"
|
||||
x1="-.828%"
|
||||
x2="57.636%"
|
||||
y1="7.652%"
|
||||
y2="78.411%"
|
||||
><stop offset="0%" stop-color="#41D1FF"></stop><stop
|
||||
offset="100%"
|
||||
stop-color="#BD34FE"
|
||||
></stop></linearGradient><linearGradient
|
||||
id="IconifyId1813088fe1fbc01fb467"
|
||||
x1="43.376%"
|
||||
x2="50.316%"
|
||||
y1="2.242%"
|
||||
y2="89.03%"
|
||||
><stop offset="0%" stop-color="#FFEA83"></stop><stop
|
||||
offset="8.333%"
|
||||
stop-color="#FFDD35"
|
||||
></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path
|
||||
fill="url(#IconifyId1813088fe1fbc01fb466)"
|
||||
d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"
|
||||
></path><path
|
||||
fill="url(#IconifyId1813088fe1fbc01fb467)"
|
||||
d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"
|
||||
></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,9 +1,10 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import comlink from 'vite-plugin-comlink';
|
||||
import glsl from 'vite-plugin-glsl';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -20,5 +21,36 @@ export default defineConfig({
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['three']
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 2000
|
||||
},
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
projects: [
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'client',
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'firefox', headless: true }]
|
||||
},
|
||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/lib/server/**']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,13 @@
|
||||
"value": 1,
|
||||
"hidden": true
|
||||
},
|
||||
"rotation": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"value": 0.5,
|
||||
"hidden": true
|
||||
},
|
||||
"depth": {
|
||||
"type": "integer",
|
||||
"min": 1,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use glam::{Mat4, Quat, Vec3};
|
||||
use nodarium_macros::nodarium_execute;
|
||||
use nodarium_macros::nodarium_definition_file;
|
||||
use nodarium_macros::{nodarium_execute, nodarium_definition_file};
|
||||
use nodarium_utils::{
|
||||
concat_args, evaluate_float, evaluate_int,
|
||||
geometry::{
|
||||
create_instance_data, wrap_geometry_data, wrap_instance_data, wrap_path,
|
||||
},
|
||||
log, split_args,
|
||||
geometry::{create_instance_data, wrap_geometry_data, wrap_instance_data, wrap_path},
|
||||
split_args,
|
||||
};
|
||||
|
||||
nodarium_definition_file!("src/input.json");
|
||||
@@ -15,13 +12,13 @@ nodarium_definition_file!("src/input.json");
|
||||
pub fn execute(input: &[i32]) -> Vec<i32> {
|
||||
let args = split_args(input);
|
||||
let mut inputs = split_args(args[0]);
|
||||
log!("WASM(instance): inputs: {:?}", inputs);
|
||||
|
||||
let mut geo_data = args[1].to_vec();
|
||||
let geo = wrap_geometry_data(&mut geo_data);
|
||||
|
||||
let mut transforms: Vec<Mat4> = Vec::new();
|
||||
|
||||
// Find max depth
|
||||
let mut max_depth = 0;
|
||||
for path_data in inputs.iter() {
|
||||
if path_data[2] != 0 {
|
||||
@@ -30,7 +27,8 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
|
||||
max_depth = max_depth.max(path_data[3]);
|
||||
}
|
||||
|
||||
let depth = evaluate_int(args[5]);
|
||||
let rotation = evaluate_float(args[5]);
|
||||
let depth = evaluate_int(args[6]);
|
||||
|
||||
for path_data in inputs.iter() {
|
||||
if path_data[3] < (max_depth - depth + 1) {
|
||||
@@ -38,24 +36,34 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
|
||||
}
|
||||
|
||||
let amount = evaluate_int(args[2]);
|
||||
|
||||
let lowest_instance = evaluate_float(args[3]);
|
||||
let highest_instance = evaluate_float(args[4]);
|
||||
|
||||
let path = wrap_path(path_data);
|
||||
|
||||
for i in 0..amount {
|
||||
let alpha =
|
||||
lowest_instance + (i as f32 / amount as f32) * (highest_instance - lowest_instance);
|
||||
let alpha = lowest_instance
|
||||
+ (i as f32 / (amount - 1) as f32) * (highest_instance - lowest_instance);
|
||||
|
||||
let point = path.get_point_at(alpha);
|
||||
let direction = path.get_direction_at(alpha);
|
||||
let tangent = path.get_direction_at(alpha);
|
||||
let size = point[3] + 0.01;
|
||||
|
||||
let axis_rotation = Quat::from_axis_angle(
|
||||
Vec3::from_slice(&tangent).normalize(),
|
||||
i as f32 * rotation,
|
||||
);
|
||||
|
||||
let path_rotation = Quat::from_rotation_arc(Vec3::Y, Vec3::from_slice(&tangent).normalize());
|
||||
|
||||
let rotation = path_rotation * axis_rotation;
|
||||
|
||||
let transform = Mat4::from_scale_rotation_translation(
|
||||
Vec3::new(point[3], point[3], point[3]),
|
||||
Quat::from_xyzw(direction[0], direction[1], direction[2], 1.0).normalize(),
|
||||
Vec3::new(size, size, size),
|
||||
rotation,
|
||||
Vec3::from_slice(&point),
|
||||
);
|
||||
|
||||
transforms.push(transform);
|
||||
}
|
||||
}
|
||||
@@ -67,11 +75,11 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
|
||||
);
|
||||
let mut instances = wrap_instance_data(&mut instance_data);
|
||||
instances.set_geometry(geo);
|
||||
(0..transforms.len()).for_each(|i| {
|
||||
instances.set_transformation_matrix(i, &transforms[i].to_cols_array());
|
||||
});
|
||||
|
||||
log!("WASM(instance): geo: {:?}", instance_data);
|
||||
for (i, transform) in transforms.iter().enumerate() {
|
||||
instances.set_transformation_matrix(i, &transform.to_cols_array());
|
||||
}
|
||||
|
||||
inputs.push(&instance_data);
|
||||
|
||||
concat_args(inputs)
|
||||
|
||||
6
nodes/max/plantarium/leaf/.gitignore
vendored
Normal file
6
nodes/max/plantarium/leaf/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
bin/
|
||||
pkg/
|
||||
wasm-pack.log
|
||||
12
nodes/max/plantarium/leaf/Cargo.toml
Normal file
12
nodes/max/plantarium/leaf/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "leaf"
|
||||
version = "0.1.0"
|
||||
authors = ["Max Richter <jim-x@web.de>"]
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
|
||||
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }
|
||||
24
nodes/max/plantarium/leaf/src/input.json
Normal file
24
nodes/max/plantarium/leaf/src/input.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "max/plantarium/leaf",
|
||||
"outputs": [
|
||||
"geometry"
|
||||
],
|
||||
"inputs": {
|
||||
"shape": {
|
||||
"type": "shape",
|
||||
"external": true
|
||||
},
|
||||
"size": {
|
||||
"type": "float",
|
||||
"value": 1
|
||||
},
|
||||
"xResolution": {
|
||||
"type": "integer",
|
||||
"description": "The amount of stems to produce",
|
||||
"min": 1,
|
||||
"max": 64,
|
||||
"value": 1,
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
166
nodes/max/plantarium/leaf/src/lib.rs
Normal file
166
nodes/max/plantarium/leaf/src/lib.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use std::convert::TryInto;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use nodarium_macros::nodarium_definition_file;
|
||||
use nodarium_macros::nodarium_execute;
|
||||
use nodarium_utils::encode_float;
|
||||
use nodarium_utils::evaluate_float;
|
||||
use nodarium_utils::evaluate_int;
|
||||
use nodarium_utils::log;
|
||||
use nodarium_utils::wrap_arg;
|
||||
use nodarium_utils::{split_args, decode_float};
|
||||
|
||||
nodarium_definition_file!("src/input.json");
|
||||
|
||||
fn calculate_y(x: f32) -> f32 {
|
||||
let term1 = (x * PI * 2.0).sin().abs();
|
||||
let term2 = (x * 2.0 * PI + (PI / 2.0)).sin() / 2.0;
|
||||
term1 + term2
|
||||
}
|
||||
|
||||
// Helper vector math functions
|
||||
fn vec_sub(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn vec_cross(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
}
|
||||
|
||||
fn vec_normalize(v: &[f32; 3]) -> [f32; 3] {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len == 0.0 { [0.0, 0.0, 0.0] } else { [v[0]/len, v[1]/len, v[2]/len] }
|
||||
}
|
||||
|
||||
#[nodarium_execute]
|
||||
pub fn execute(input: &[i32]) -> Vec<i32> {
|
||||
let args = split_args(input);
|
||||
let input_path = split_args(args[0])[0];
|
||||
let size = evaluate_float(args[1]);
|
||||
let width_resolution = evaluate_int(args[2]).max(3) as usize;
|
||||
let path_length = (input_path.len() - 4) / 2;
|
||||
|
||||
let slice_count = path_length;
|
||||
let face_amount = (slice_count - 1) * (width_resolution - 1) * 2;
|
||||
let position_amount = slice_count * width_resolution;
|
||||
|
||||
let out_length =
|
||||
3 // metadata
|
||||
+ face_amount * 3 // indices
|
||||
+ position_amount * 3 // positions
|
||||
+ position_amount * 3; // normals
|
||||
|
||||
let mut out = vec![0 as i32; out_length];
|
||||
|
||||
log!("face_amount={:?} position_amount={:?}", face_amount, position_amount);
|
||||
|
||||
out[0] = 1;
|
||||
out[1] = position_amount.try_into().unwrap();
|
||||
out[2] = face_amount.try_into().unwrap();
|
||||
let mut offset = 3;
|
||||
|
||||
// Writing Indices
|
||||
let mut idx = 0;
|
||||
for i in 0..(slice_count - 1) {
|
||||
let base0 = (i * width_resolution) as i32;
|
||||
let base1 = ((i + 1) * width_resolution) as i32;
|
||||
|
||||
for j in 0..(width_resolution - 1) {
|
||||
let a = base0 + j as i32;
|
||||
let b = base0 + j as i32 + 1;
|
||||
let c = base1 + j as i32;
|
||||
let d = base1 + j as i32 + 1;
|
||||
|
||||
// triangle 1
|
||||
out[offset + idx + 0] = a;
|
||||
out[offset + idx + 1] = b;
|
||||
out[offset + idx + 2] = c;
|
||||
|
||||
// triangle 2
|
||||
out[offset + idx + 3] = b;
|
||||
out[offset + idx + 4] = d;
|
||||
out[offset + idx + 5] = c;
|
||||
|
||||
idx += 6;
|
||||
}
|
||||
}
|
||||
|
||||
offset += face_amount * 3;
|
||||
|
||||
// Writing Positions
|
||||
let width = 50.0;
|
||||
let mut positions = vec![[0.0f32; 3]; position_amount];
|
||||
for i in 0..slice_count {
|
||||
let ax = i as f32 / (slice_count -1) as f32;
|
||||
|
||||
let px = decode_float(input_path[2 + i * 2 + 0]);
|
||||
let pz = decode_float(input_path[2 + i * 2 + 1]);
|
||||
|
||||
|
||||
for j in 0..width_resolution {
|
||||
let alpha = j as f32 / (width_resolution - 1) as f32;
|
||||
let x = 2.0 * (-px * (alpha - 0.5) + alpha * width);
|
||||
let py = calculate_y(alpha-0.5)*5.0*(ax*PI).sin();
|
||||
let pz_val = pz - 100.0;
|
||||
|
||||
let pos_idx = i * width_resolution + j;
|
||||
positions[pos_idx] = [x - width, py, pz_val];
|
||||
|
||||
let flat_idx = offset + pos_idx * 3;
|
||||
out[flat_idx + 0] = encode_float((x - width) * size);
|
||||
out[flat_idx + 1] = encode_float(py * size);
|
||||
out[flat_idx + 2] = encode_float(pz_val * size);
|
||||
}
|
||||
}
|
||||
|
||||
// Writing Normals
|
||||
offset += position_amount * 3;
|
||||
let mut normals = vec![[0.0f32; 3]; position_amount];
|
||||
|
||||
for i in 0..(slice_count - 1) {
|
||||
for j in 0..(width_resolution - 1) {
|
||||
let a = i * width_resolution + j;
|
||||
let b = i * width_resolution + j + 1;
|
||||
let c = (i + 1) * width_resolution + j;
|
||||
let d = (i + 1) * width_resolution + j + 1;
|
||||
|
||||
// triangle 1: a,b,c
|
||||
let u = vec_sub(&positions[b], &positions[a]);
|
||||
let v = vec_sub(&positions[c], &positions[a]);
|
||||
let n1 = vec_cross(&u, &v);
|
||||
|
||||
// triangle 2: b,d,c
|
||||
let u2 = vec_sub(&positions[d], &positions[b]);
|
||||
let v2 = vec_sub(&positions[c], &positions[b]);
|
||||
let n2 = vec_cross(&u2, &v2);
|
||||
|
||||
for &idx in &[a, b, c] {
|
||||
normals[idx][0] += n1[0];
|
||||
normals[idx][1] += n1[1];
|
||||
normals[idx][2] += n1[2];
|
||||
}
|
||||
|
||||
for &idx in &[b, d, c] {
|
||||
normals[idx][0] += n2[0];
|
||||
normals[idx][1] += n2[1];
|
||||
normals[idx][2] += n2[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalize and write to output
|
||||
for i in 0..position_amount {
|
||||
let n = vec_normalize(&normals[i]);
|
||||
let flat_idx = offset + i * 3;
|
||||
out[flat_idx + 0] = encode_float(n[0]);
|
||||
out[flat_idx + 1] = encode_float(n[1]);
|
||||
out[flat_idx + 2] = encode_float(n[2]);
|
||||
}
|
||||
|
||||
wrap_arg(&out)
|
||||
}
|
||||
|
||||
6
nodes/max/plantarium/shape/.gitignore
vendored
Normal file
6
nodes/max/plantarium/shape/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
bin/
|
||||
pkg/
|
||||
wasm-pack.log
|
||||
12
nodes/max/plantarium/shape/Cargo.toml
Normal file
12
nodes/max/plantarium/shape/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "shape"
|
||||
version = "0.1.0"
|
||||
authors = ["Max Richter <jim-x@web.de>"]
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
|
||||
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }
|
||||
27
nodes/max/plantarium/shape/src/input.json
Normal file
27
nodes/max/plantarium/shape/src/input.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "max/plantarium/shape",
|
||||
"outputs": [
|
||||
"shape"
|
||||
],
|
||||
"inputs": {
|
||||
"shape": {
|
||||
"type": "shape",
|
||||
"internal": true,
|
||||
"value": [
|
||||
47.8,
|
||||
100,
|
||||
47.8,
|
||||
82.8,
|
||||
30.9,
|
||||
69.1,
|
||||
23.2,
|
||||
40.7,
|
||||
27.1,
|
||||
14.5,
|
||||
42.5,
|
||||
0
|
||||
],
|
||||
"label": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
10
nodes/max/plantarium/shape/src/lib.rs
Normal file
10
nodes/max/plantarium/shape/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use nodarium_macros::nodarium_definition_file;
|
||||
use nodarium_macros::nodarium_execute;
|
||||
use nodarium_utils::{concat_args, split_args};
|
||||
|
||||
nodarium_definition_file!("src/input.json");
|
||||
|
||||
#[nodarium_execute]
|
||||
pub fn execute(input: &[i32]) -> Vec<i32> {
|
||||
concat_args(split_args(input))
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"version": "0.0.4",
|
||||
"scripts": {
|
||||
"postinstall": "pnpm run -r --filter 'ui' build",
|
||||
"build": "pnpm build:nodes && pnpm build:app",
|
||||
"lint": "pnpm run -r --parallel lint",
|
||||
"format": "pnpm dprint fmt",
|
||||
"format:check": "pnpm dprint check",
|
||||
"check": "pnpm run -r check",
|
||||
"build:story": "pnpm -r --filter 'ui' story:build",
|
||||
"test": "pnpm run -r --parallel test",
|
||||
"check": "pnpm run -r --parallel check",
|
||||
"build": "pnpm build:nodes && pnpm build:app",
|
||||
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app' build",
|
||||
"build:nodes": "cargo build --workspace --target wasm32-unknown-unknown --release && rm -rf ./app/static/nodes/max/plantarium/ && mkdir -p ./app/static/nodes/max/plantarium/ && cp -R ./target/wasm32-unknown-unknown/release/*.wasm ./app/static/nodes/max/plantarium/",
|
||||
"build:deploy": "pnpm build && cp -R packages/ui/build app/build/ui",
|
||||
"dev:nodes": "chokidar './nodes/**' --initial -i '/pkg/' -c 'pnpm build:nodes'",
|
||||
"dev:app_ui": "pnpm -r --parallel --filter 'app' --filter './packages/ui' dev",
|
||||
"dev_ui": "pnpm -r --filter 'ui' dev:ui",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodarium/types",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.4",
|
||||
"description": "",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
@@ -17,7 +17,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dprint": "^0.51.1"
|
||||
|
||||
@@ -26,22 +26,32 @@ const DefaultOptionsSchema = z.object({
|
||||
export const NodeInputFloatSchema = z.object({
|
||||
...DefaultOptionsSchema.shape,
|
||||
type: z.literal('float'),
|
||||
element: z.literal('slider').optional(),
|
||||
value: z.number().optional(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
step: z.number().optional()
|
||||
});
|
||||
|
||||
export const NodeInputColorSchema = z.object({
|
||||
...DefaultOptionsSchema.shape,
|
||||
type: z.literal('color'),
|
||||
value: z.array(z.number()).optional()
|
||||
});
|
||||
|
||||
export const NodeInputIntegerSchema = z.object({
|
||||
...DefaultOptionsSchema.shape,
|
||||
type: z.literal('integer'),
|
||||
element: z.literal('slider').optional(),
|
||||
value: z.number().optional(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional()
|
||||
});
|
||||
|
||||
export const NodeInputShapeSchema = z.object({
|
||||
...DefaultOptionsSchema.shape,
|
||||
type: z.literal('shape'),
|
||||
value: z.array(z.number()).optional()
|
||||
});
|
||||
|
||||
export const NodeInputBooleanSchema = z.object({
|
||||
...DefaultOptionsSchema.shape,
|
||||
type: z.literal('boolean'),
|
||||
@@ -83,7 +93,9 @@ export const NodeInputSchema = z.union([
|
||||
NodeInputSeedSchema,
|
||||
NodeInputBooleanSchema,
|
||||
NodeInputFloatSchema,
|
||||
NodeInputColorSchema,
|
||||
NodeInputIntegerSchema,
|
||||
NodeInputShapeSchema,
|
||||
NodeInputSelectSchema,
|
||||
NodeInputSeedSchema,
|
||||
NodeInputVec3Schema,
|
||||
|
||||
@@ -103,6 +103,15 @@ pub struct NodeInputVec3 {
|
||||
pub value: Option<Vec<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct NodeInputShape {
|
||||
#[serde(flatten)]
|
||||
pub default_options: DefaultOptions,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Vec<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct NodeInputGeometry {
|
||||
#[serde(flatten)]
|
||||
@@ -125,6 +134,7 @@ pub enum NodeInput {
|
||||
select(NodeInputSelect),
|
||||
seed(NodeInputSeed),
|
||||
vec3(NodeInputVec3),
|
||||
shape(NodeInputShape),
|
||||
geometry(NodeInputGeometry),
|
||||
path(NodeInputPath),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodarium/ui",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && npm run package",
|
||||
@@ -33,28 +33,32 @@
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@nodarium/types": "workspace:",
|
||||
"@nodarium/types": "workspace:^",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/package": "^2.5.7",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/three": "^0.182.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"dprint": "^0.51.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-svelte": "^3.14.0",
|
||||
"globals": "^17.3.0",
|
||||
"publint": "^0.3.16",
|
||||
"svelte": "^5.46.4",
|
||||
"svelte-check": "^4.3.5",
|
||||
"publint": "^0.3.17",
|
||||
"svelte": "^5.49.2",
|
||||
"svelte-check": "^4.3.6",
|
||||
"svelte-eslint-parser": "^1.4.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17"
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-browser-svelte": "^2.0.2"
|
||||
},
|
||||
"svelte": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type Snippet } from 'svelte';
|
||||
interface Props {
|
||||
title?: string;
|
||||
transparent?: boolean;
|
||||
children?: Snippet;
|
||||
open?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
let { title = 'Details', transparent = false, children, open = $bindable(false) }: Props =
|
||||
$props();
|
||||
let {
|
||||
title = 'Details',
|
||||
transparent = false,
|
||||
children,
|
||||
open = $bindable(false),
|
||||
class: _class
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<details class:transparent bind:open class="text-text outline-1 outline-outline bg-layer-1">
|
||||
<details
|
||||
class:transparent
|
||||
bind:open
|
||||
class="text-text outline-1 outline-outline bg-layer-2 {_class}"
|
||||
>
|
||||
<summary>{title}</summary>
|
||||
<div class="content">
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
details {
|
||||
border-radius: 2px;
|
||||
}
|
||||
summary {
|
||||
padding: 1em;
|
||||
padding-left: 20px;
|
||||
border-radius: 2px;
|
||||
font-weight: 300;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
details[open] > summary {
|
||||
border-bottom: solid thin var(--color-outline);
|
||||
}
|
||||
|
||||
details > div {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
details.transparent {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
|
||||
13
packages/ui/src/lib/Details.svelte.ts
Normal file
13
packages/ui/src/lib/Details.svelte.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import Details from './Details.svelte';
|
||||
|
||||
describe('Details', () => {
|
||||
it('should render summary element', async () => {
|
||||
render(Details, { title: 'Click me' });
|
||||
|
||||
const summary = page.getByText('Click me');
|
||||
await expect.element(summary).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { NodeInput } from '@nodarium/types';
|
||||
|
||||
import { InputCheckbox, InputNumber, InputSelect, InputVec3 } from './index';
|
||||
import {
|
||||
InputCheckbox,
|
||||
InputColor,
|
||||
InputNumber,
|
||||
InputSelect,
|
||||
InputShape,
|
||||
InputVec3
|
||||
} from './index';
|
||||
|
||||
interface Props {
|
||||
input: NodeInput;
|
||||
@@ -19,8 +26,17 @@
|
||||
max={input?.max}
|
||||
step={input?.step}
|
||||
/>
|
||||
{:else if input.type === 'shape'}
|
||||
<InputShape bind:value={value as number[]} />
|
||||
{:else if input.type === 'color'}
|
||||
<InputColor bind:value={value as [number, number, number]} />
|
||||
{:else if input.type === 'integer'}
|
||||
<InputNumber bind:value={value as number} min={input?.min} max={input?.max} step={1} />
|
||||
<InputNumber
|
||||
bind:value={value as number}
|
||||
min={input?.min}
|
||||
max={input?.max}
|
||||
step={1}
|
||||
/>
|
||||
{:else if input.type === 'boolean'}
|
||||
<InputCheckbox bind:value={value as boolean} {id} />
|
||||
{:else if input.type === 'select'}
|
||||
|
||||
34
packages/ui/src/lib/ShortCut.svelte.ts
Normal file
34
packages/ui/src/lib/ShortCut.svelte.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ShortCut from './ShortCut.svelte';
|
||||
|
||||
describe('ShortCut', () => {
|
||||
it('should render with key label', async () => {
|
||||
render(ShortCut, { key: 'S' });
|
||||
|
||||
const shortcut = page.getByText('S');
|
||||
await expect.element(shortcut).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render ctrl modifier', async () => {
|
||||
render(ShortCut, { ctrl: true, key: 'S' });
|
||||
|
||||
const shortcut = page.getByText(/Ctrl/);
|
||||
await expect.element(shortcut).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render alt modifier', async () => {
|
||||
render(ShortCut, { alt: true, key: 'F4' });
|
||||
|
||||
const shortcut = page.getByText(/Alt/);
|
||||
await expect.element(shortcut).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple modifiers', async () => {
|
||||
render(ShortCut, { ctrl: true, alt: true, key: 'Delete' });
|
||||
|
||||
const shortcut = page.getByText(/Ctrl/);
|
||||
await expect.element(shortcut).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}selected");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}outline{!,}");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}connection");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}edge");
|
||||
@source inline("{hover:,}{bg-,outline-,text-,}text");
|
||||
|
||||
/* fira-code-300 - latin */
|
||||
@@ -72,12 +71,12 @@
|
||||
|
||||
--color-outline: var(--neutral-400);
|
||||
--color-connection: #333333;
|
||||
--color-edge: var(--connection, var(--color-outline));
|
||||
|
||||
--color-text: var(--neutral-200);
|
||||
}
|
||||
|
||||
html {
|
||||
--neutral-050: #f0f0f0;
|
||||
--neutral-100: #e7e7e7;
|
||||
--neutral-200: #cecece;
|
||||
--neutral-300: #7c7c7c;
|
||||
@@ -89,14 +88,13 @@ html {
|
||||
--color-layer-0: var(--neutral-900);
|
||||
--color-layer-1: var(--neutral-500);
|
||||
--color-layer-2: var(--neutral-400);
|
||||
--color-layer-3: var(--neutral-200);
|
||||
--color-layer-3: var(--neutral-300);
|
||||
|
||||
--color-active: #ffffff;
|
||||
--color-selected: #c65a19;
|
||||
|
||||
--color-outline: var(--neutral-400);
|
||||
--color-outline: #3e3e3e;
|
||||
--color-connection: #333333;
|
||||
--color-edge: var(--connection, var(--color-outline));
|
||||
|
||||
--color-text-color: var(--neutral-200);
|
||||
}
|
||||
@@ -110,10 +108,10 @@ body {
|
||||
html.theme-light {
|
||||
--color-text: var(--neutral-800);
|
||||
--color-outline: var(--neutral-300);
|
||||
--color-layer-0: var(--neutral-100);
|
||||
--color-layer-0: var(--neutral-050);
|
||||
--color-layer-1: var(--neutral-100);
|
||||
--color-layer-2: var(--neutral-200);
|
||||
--color-layer-3: var(--neutral-500);
|
||||
--color-layer-3: var(--neutral-300);
|
||||
--color-active: #000000;
|
||||
--color-selected: #c65a19;
|
||||
--color-connection: #888;
|
||||
@@ -142,15 +140,29 @@ html.theme-catppuccin {
|
||||
}
|
||||
|
||||
html.theme-high-contrast {
|
||||
--color-text: #ffffff;
|
||||
--color-text: white;
|
||||
--color-outline: white;
|
||||
--color-layer-0: #000000;
|
||||
--color-layer-0: black;
|
||||
--color-layer-1: black;
|
||||
--color-layer-2: #222222;
|
||||
--color-layer-3: #ffffff;
|
||||
--color-layer-2: black;
|
||||
--color-layer-3: white;
|
||||
--color-active: #00ff00;
|
||||
--color-selected: #ff0000;
|
||||
--color-connection: #fff;
|
||||
}
|
||||
|
||||
html.theme-high-contrast-light {
|
||||
--color-text: black;
|
||||
--color-outline: black;
|
||||
--color-layer-0: white;
|
||||
--color-layer-1: white;
|
||||
--color-layer-2: white;
|
||||
--color-layer-3: black;
|
||||
--color-active: #00ffff;
|
||||
--color-selected: #ff0000;
|
||||
--color-connection: black;
|
||||
}
|
||||
|
||||
html.theme-nord {
|
||||
--color-text: #d8dee9;
|
||||
--color-outline: #4c566a;
|
||||
|
||||
55
packages/ui/src/lib/helpers/getBoundingValue.test.ts
Normal file
55
packages/ui/src/lib/helpers/getBoundingValue.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getBoundingValue } from './getBoundingValue';
|
||||
|
||||
describe('getBoundingValue', () => {
|
||||
it('should return 1 for values between 0 and 1', () => {
|
||||
expect(getBoundingValue(0)).toBe(1);
|
||||
expect(getBoundingValue(0.5)).toBe(1);
|
||||
expect(getBoundingValue(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 2 for values between 1 and 2', () => {
|
||||
expect(getBoundingValue(1.1)).toBe(2);
|
||||
expect(getBoundingValue(1.5)).toBe(2);
|
||||
expect(getBoundingValue(2)).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 4 for values between 2 and 4', () => {
|
||||
expect(getBoundingValue(2.1)).toBe(4);
|
||||
expect(getBoundingValue(3)).toBe(4);
|
||||
expect(getBoundingValue(4)).toBe(4);
|
||||
});
|
||||
|
||||
it('should return positive level for positive input', () => {
|
||||
expect(getBoundingValue(5)).toBe(10);
|
||||
expect(getBoundingValue(15)).toBe(20);
|
||||
expect(getBoundingValue(50)).toBe(50);
|
||||
expect(getBoundingValue(150)).toBe(200);
|
||||
});
|
||||
|
||||
it('should return negative level for negative input', () => {
|
||||
expect(getBoundingValue(-5)).toBe(-10);
|
||||
expect(getBoundingValue(-15)).toBe(-20);
|
||||
expect(getBoundingValue(-50)).toBe(-50);
|
||||
expect(getBoundingValue(-150)).toBe(-200);
|
||||
});
|
||||
|
||||
it('should return correct level for boundary values', () => {
|
||||
expect(getBoundingValue(10)).toBe(10);
|
||||
expect(getBoundingValue(20)).toBe(20);
|
||||
expect(getBoundingValue(50)).toBe(50);
|
||||
expect(getBoundingValue(100)).toBe(100);
|
||||
expect(getBoundingValue(200)).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle large values', () => {
|
||||
expect(getBoundingValue(1000)).toBe(1000);
|
||||
expect(getBoundingValue(1500)).toBe(1000);
|
||||
expect(getBoundingValue(-1000)).toBe(-1000);
|
||||
});
|
||||
|
||||
it('should handle very small values', () => {
|
||||
expect(getBoundingValue(0.001)).toBe(1);
|
||||
expect(getBoundingValue(-0.001)).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
export { default as Input } from './Input.svelte';
|
||||
export { default as InputCheckbox } from './inputs/InputCheckbox.svelte';
|
||||
export { default as InputColor } from './inputs/InputColor.svelte';
|
||||
export { default as InputNumber } from './inputs/InputNumber.svelte';
|
||||
export { default as InputSelect } from './inputs/InputSelect.svelte';
|
||||
export { default as InputShape } from './inputs/InputShape.svelte';
|
||||
export { default as InputVec3 } from './inputs/InputVec3.svelte';
|
||||
|
||||
export { default as Details } from './Details.svelte';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="relative inline-flex h-5.5 w-5.5 cursor-pointer items-center justify-center bg-layer-2 rounded-[5px]"
|
||||
class="relative inline-flex h-5.5 w-5.5 cursor-pointer items-center justify-center bg-layer-2 outline-1 outline-outline rounded-[5px]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -27,7 +27,7 @@
|
||||
{id}
|
||||
/>
|
||||
<span
|
||||
class="absolute opacity-0 peer-checked:opacity-100 transition-opacity duration-100 flex w-full h-full items-center justify-center"
|
||||
class="absolute opacity-0 peer-checked:opacity-100 transition-opacity duration-50 flex w-full h-full items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 19 14"
|
||||
|
||||
27
packages/ui/src/lib/inputs/InputCheckbox.svelte.ts
Normal file
27
packages/ui/src/lib/inputs/InputCheckbox.svelte.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import InputCheckbox from './InputCheckbox.svelte';
|
||||
|
||||
describe('InputCheckbox', () => {
|
||||
it('should render checkbox label', async () => {
|
||||
render(InputCheckbox, { value: false });
|
||||
|
||||
const checkbox = page.getByRole('checkbox');
|
||||
await expect.element(checkbox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be unchecked when value is false', async () => {
|
||||
render(InputCheckbox, { value: false });
|
||||
|
||||
const input = page.getByRole('checkbox');
|
||||
await expect.element(input).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should be checked when value is true', async () => {
|
||||
render(InputCheckbox, { value: true });
|
||||
|
||||
const input = page.getByRole('checkbox');
|
||||
await expect.element(input).toBeChecked();
|
||||
});
|
||||
});
|
||||
68
packages/ui/src/lib/inputs/InputColor.svelte
Normal file
68
packages/ui/src/lib/inputs/InputColor.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: [number, number, number];
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable([255, 255, 255] as [number, number, number]),
|
||||
id
|
||||
}: Props = $props();
|
||||
|
||||
let hexValue = $derived(
|
||||
`#${value.map((c) => c.toString(16).padStart(2, '0')).join('')}`
|
||||
);
|
||||
|
||||
function handleHexInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let val = target.value.replace(/[^0-9a-fA-F]/g, '');
|
||||
if (val.length > 6) val = val.slice(0, 6);
|
||||
if (val.length === 3) {
|
||||
val = val
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
}
|
||||
if (val.length === 6) {
|
||||
value = [
|
||||
parseInt(val.slice(0, 2), 16),
|
||||
parseInt(val.slice(2, 4), 16),
|
||||
parseInt(val.slice(4, 6), 16)
|
||||
] as [number, number, number];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex overflow-hidden rounded-sm border border-outline bg-layer-2 w-min">
|
||||
<label
|
||||
class="-ml-px w-8 shrink-0 overflow-hidden"
|
||||
style={`background-color: ${hexValue}`}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
bind:value={hexValue}
|
||||
{id}
|
||||
oninput={handleHexInput}
|
||||
class="h-full w-8 cursor-pointer appearance-none p-0"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-1 px-2 py-1">
|
||||
<span class="pointer-events-none text-text opacity-30">#</span>
|
||||
<input
|
||||
type="text"
|
||||
value={hexValue.slice(1)}
|
||||
{id}
|
||||
oninput={handleHexInput}
|
||||
maxlength={6}
|
||||
class="w-15 bg-transparent text-text outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input[type="color"] {
|
||||
margin-top: -1px;
|
||||
margin-right: -1px;
|
||||
height: calc(100% + 2px);
|
||||
}
|
||||
</style>
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
// normalize bounds
|
||||
if (min > max) [min, max] = [max, min];
|
||||
if (value > max) max = value;
|
||||
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
@@ -27,11 +26,18 @@
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function snap(v: number) {
|
||||
if (step) v = Math.round(v / step) * step;
|
||||
function snap(v: number, s = step) {
|
||||
if (s) v = Math.round(v / s) * s;
|
||||
return +v.toFixed(3);
|
||||
}
|
||||
|
||||
function getAutoStep(v: number): number {
|
||||
const abs = Math.abs(v);
|
||||
if (abs === 0) return 0.1; // fallback for 0
|
||||
const exponent = Math.floor(Math.log10(abs));
|
||||
return Math.pow(10, exponent);
|
||||
}
|
||||
|
||||
let dragging = $state(false);
|
||||
let startValue = 0;
|
||||
let rect: DOMRect;
|
||||
@@ -59,7 +65,8 @@
|
||||
value = snap(
|
||||
e.ctrlKey
|
||||
? startValue + delta
|
||||
: clamp(startValue + delta)
|
||||
: clamp(startValue + delta),
|
||||
(e.altKey && !step) ? getAutoStep(value) : step
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,7 +112,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
value = snap(clamp(value));
|
||||
value = snap(value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -130,7 +137,7 @@
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-layer-3 opacity-30 pointer-events-none transition-[width]"
|
||||
class:transition-none={dragging}
|
||||
style={`width: ${Math.min((value - min) / (max - min), 1) * 100}%`}
|
||||
style={`width: ${Math.max(0, Math.min((value - min) / (max - min), 1) * 100)}%`}
|
||||
>
|
||||
</div>
|
||||
|
||||
|
||||
59
packages/ui/src/lib/inputs/InputNumber.svelte.ts
Normal file
59
packages/ui/src/lib/inputs/InputNumber.svelte.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import InputNumber from './InputNumber.svelte';
|
||||
|
||||
describe('InputNumber', () => {
|
||||
it('should render input element', async () => {
|
||||
render(InputNumber, { value: 0.5 });
|
||||
|
||||
const input = page.getByRole('spinbutton');
|
||||
await expect.element(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with step buttons when step is provided', async () => {
|
||||
render(InputNumber, { value: 0.5, step: 0.1 });
|
||||
|
||||
const decrementBtn = page.getByRole('button', { name: 'step down' });
|
||||
const incrementBtn = page.getByRole('button', { name: 'step up' });
|
||||
await expect.element(decrementBtn).toBeInTheDocument();
|
||||
await expect.element(incrementBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render step buttons when step is undefined', async () => {
|
||||
const screen = render(InputNumber, { value: 0.5 });
|
||||
|
||||
const buttons = screen.locator.getByRole('button');
|
||||
const count = buttons.all().length;
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should accept numeric value', async () => {
|
||||
render(InputNumber, { value: 42 });
|
||||
|
||||
const input = page.getByRole('spinbutton');
|
||||
await expect.element(input).toHaveValue(42);
|
||||
});
|
||||
|
||||
it('should accept min and max bounds', async () => {
|
||||
render(InputNumber, { value: 5, min: 0, max: 10 });
|
||||
|
||||
const input = page.getByRole('spinbutton');
|
||||
await expect.element(input).toHaveAttribute('min', '0');
|
||||
await expect.element(input).toHaveAttribute('max', '10');
|
||||
});
|
||||
|
||||
it('should not clamp value on init when value exceeds min/max', async () => {
|
||||
render(InputNumber, { value: 100, min: 0, max: 10 });
|
||||
|
||||
const input = page.getByRole('spinbutton');
|
||||
await expect.element(input).toHaveValue(100);
|
||||
});
|
||||
|
||||
it('should not clamp value on init when value is below min', async () => {
|
||||
render(InputNumber, { value: -50, min: 0, max: 10 });
|
||||
|
||||
const input = page.getByRole('spinbutton');
|
||||
await expect.element(input).toHaveValue(-50);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@
|
||||
select {
|
||||
font-family: var(--font-family);
|
||||
outline: solid 1px var(--color-outline);
|
||||
padding: 0.8em 1em;
|
||||
padding: 0.5em 0.8em;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
27
packages/ui/src/lib/inputs/InputSelect.svelte.ts
Normal file
27
packages/ui/src/lib/inputs/InputSelect.svelte.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import InputSelect from './InputSelect.svelte';
|
||||
|
||||
describe('InputSelect', () => {
|
||||
it('should render select element', async () => {
|
||||
render(InputSelect, { options: ['a', 'b', 'c'], value: 0 });
|
||||
|
||||
const select = page.getByRole('combobox');
|
||||
await expect.element(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all options', async () => {
|
||||
render(InputSelect, { options: ['apple', 'banana', 'cherry'], value: 0 });
|
||||
|
||||
const select = page.getByRole('combobox');
|
||||
await expect.element(select).toHaveTextContent('applebananacherry');
|
||||
});
|
||||
|
||||
it('should select correct option by index', async () => {
|
||||
render(InputSelect, { options: ['first', 'second', 'third'], value: 1 });
|
||||
|
||||
const select = page.getByRole('combobox');
|
||||
await expect.element(select).toHaveTextContent('second');
|
||||
});
|
||||
});
|
||||
287
packages/ui/src/lib/inputs/InputShape.svelte
Normal file
287
packages/ui/src/lib/inputs/InputShape.svelte
Normal file
@@ -0,0 +1,287 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
value: number[];
|
||||
mirror?: boolean;
|
||||
};
|
||||
|
||||
let { value: points = $bindable(), mirror = true }: Props = $props();
|
||||
|
||||
let mouseDown = $state<number[]>();
|
||||
let draggingIndex = $state<number>();
|
||||
let downCirclePosition = $state<number[]>();
|
||||
let svgElement = $state<SVGElement>(null!);
|
||||
let svgRect = $state<DOMRect>(null!);
|
||||
let isMirroredEvent = $state(false);
|
||||
|
||||
const pathD = $derived(calculatePath(points, mirror));
|
||||
const groupedPoints = $derived(group(points));
|
||||
|
||||
function group<T>(arr: T[], size = 2): T[][] {
|
||||
const result = [];
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
result.push(arr.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const dist = (a: [number, number], b: [number, number]) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
|
||||
const round = (v: number) => Math.floor(v * 10) / 10;
|
||||
const getPt = (i: number) => [points[i * 2], points[i * 2 + 1]] as [number, number];
|
||||
|
||||
$effect(() => {
|
||||
if (!points.length) {
|
||||
points = [
|
||||
47.8,
|
||||
100,
|
||||
47.8,
|
||||
82.8,
|
||||
30.9,
|
||||
69.1,
|
||||
23.2,
|
||||
40.7,
|
||||
27.1,
|
||||
14.5,
|
||||
42.5,
|
||||
0
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (mirror) {
|
||||
const _points: [number, number, number][] = [];
|
||||
for (let i = 0; i < points.length / 2; i++) {
|
||||
const pt = [...getPt(i), i] as [number, number, number];
|
||||
if (pt[0] > 50) {
|
||||
pt[0] = 100 - pt[0];
|
||||
}
|
||||
_points.push(pt);
|
||||
}
|
||||
|
||||
const sortedPoints = _points.sort((a, b) => {
|
||||
if (a[1] !== b[1]) return b[1] - a[1];
|
||||
return a[0] - b[0];
|
||||
});
|
||||
|
||||
const newIndices = new Map(sortedPoints.map((p, i) => [p[2], i]));
|
||||
|
||||
const sorted = sortedPoints.map((p) => [p[0], p[1]]).flat();
|
||||
|
||||
let sortChanged = false;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (sorted[i] !== points[i]) {
|
||||
sortChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sortChanged) {
|
||||
points = sorted;
|
||||
draggingIndex = newIndices.get(draggingIndex || 0) || 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function insertBetween(newPt: [number, number]): number {
|
||||
const count = points.length / 2;
|
||||
|
||||
if (count < 2) {
|
||||
points = [...points, ...newPt];
|
||||
return count;
|
||||
}
|
||||
|
||||
let minDist = Infinity;
|
||||
let insertIdx = 0;
|
||||
|
||||
for (let i = 0; i < count - 1; i++) {
|
||||
const a = getPt(i);
|
||||
const b = getPt(i + 1);
|
||||
const d = dist(newPt, a) + dist(newPt, b) - dist(a, b);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
insertIdx = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
points.splice(insertIdx * 2, 0, newPt[0], newPt[1]);
|
||||
return insertIdx;
|
||||
}
|
||||
|
||||
function calculatePath(pts: number[], mirror = false): string {
|
||||
if (pts.length === 0) return '';
|
||||
|
||||
const arr = [...pts];
|
||||
|
||||
let d = `M ${arr[0]} ${arr[1]}`;
|
||||
for (let i = 2; i < arr.length; i += 2) {
|
||||
d += ` L ${arr[i]} ${arr[i + 1]}`;
|
||||
}
|
||||
|
||||
if (mirror) {
|
||||
for (let i = arr.length - 2; i >= 0; i -= 2) {
|
||||
const x = 100 - arr[i];
|
||||
d += ` L ${x} ${arr[i + 1]}`;
|
||||
}
|
||||
}
|
||||
d += ' Z';
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
function handleMouseMove(ev: MouseEvent) {
|
||||
if (
|
||||
mouseDown === undefined
|
||||
|| draggingIndex === undefined
|
||||
|| !downCirclePosition
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let vx = (mouseDown[0] - ev.clientX) * (100 / svgRect.width);
|
||||
let vy = (mouseDown[1] - ev.clientY) * (100 / svgRect.height);
|
||||
|
||||
if (ev.shiftKey) {
|
||||
vx /= 10;
|
||||
vy /= 10;
|
||||
}
|
||||
|
||||
let x = downCirclePosition[0] + (isMirroredEvent ? 1 : -1) * vx;
|
||||
let y = downCirclePosition[1] - vy;
|
||||
|
||||
x = clamp(x, 0, mirror ? 50 : 100);
|
||||
y = clamp(y, 0, 100);
|
||||
|
||||
points[draggingIndex * 2] = round(x);
|
||||
points[draggingIndex * 2 + 1] = round(y);
|
||||
}
|
||||
|
||||
function handleMouseDown(ev: MouseEvent) {
|
||||
ev.preventDefault();
|
||||
isMirroredEvent = false;
|
||||
|
||||
svgRect = svgElement.getBoundingClientRect();
|
||||
mouseDown = [ev.clientX, ev.clientY];
|
||||
|
||||
const indexText = (ev.target as SVGCircleElement).dataset.index;
|
||||
|
||||
const x = ((ev.clientX - svgRect.left) / svgRect.width) * 100;
|
||||
const y = ((ev.clientY - svgRect.top) / svgRect.height) * 100;
|
||||
isMirroredEvent = mirror && x > 50;
|
||||
|
||||
if (indexText !== undefined) {
|
||||
draggingIndex = parseInt(indexText);
|
||||
downCirclePosition = getPt(draggingIndex);
|
||||
} else {
|
||||
draggingIndex = undefined;
|
||||
|
||||
const pt = [round(clamp(x, 0, 100)), round(clamp(y, 0, 100))] as [
|
||||
number,
|
||||
number
|
||||
];
|
||||
if (isMirroredEvent) {
|
||||
pt[0] = 100 - pt[0];
|
||||
}
|
||||
|
||||
draggingIndex = insertBetween(pt);
|
||||
downCirclePosition = pt;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
mouseDown = undefined;
|
||||
draggingIndex = undefined;
|
||||
}
|
||||
|
||||
function handleContextMenu(ev: MouseEvent) {
|
||||
const indexText = (ev.target as HTMLElement).dataset?.index;
|
||||
if (indexText !== undefined) {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
const index = parseInt(indexText);
|
||||
draggingIndex = undefined;
|
||||
points.splice(index * 2, 2);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseup={handleMouseUp}
|
||||
oncontextmenu={handleContextMenu}
|
||||
/>
|
||||
|
||||
<div class="wrapper" class:mirrored={mirror}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 100 100"
|
||||
bind:this={svgElement}
|
||||
aria-label="Interactive 2D Shape Editor"
|
||||
onmousedown={handleMouseDown}
|
||||
>
|
||||
<path d={pathD} style:fill="var(--color-layer-3)" style:opacity={0.3} />
|
||||
<path d={pathD} fill="none" stroke="var(--color-layer-3)" />
|
||||
{#if mirror}
|
||||
{#each groupedPoints as p, i (i)}
|
||||
{@const x = 100 - p[0]}
|
||||
{@const y = p[1]}
|
||||
<circle
|
||||
class:active={isMirroredEvent && draggingIndex === i}
|
||||
data-index={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={3}
|
||||
>
|
||||
</circle>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#each groupedPoints as p, i (i)}
|
||||
<circle
|
||||
class:active={!isMirroredEvent && draggingIndex === i}
|
||||
data-index={i}
|
||||
cx={p[0]}
|
||||
cy={p[1]}
|
||||
r={3}
|
||||
>
|
||||
</circle>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background-color: var(--color-layer-2);
|
||||
padding: 7px;
|
||||
border-radius: 5px;
|
||||
outline: solid thin var(--color-outline);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
circle {
|
||||
cursor: pointer;
|
||||
stroke: transparent;
|
||||
transition: fill 0.2s ease;
|
||||
stroke-width: 1px;
|
||||
stroke: var(--color-layer-3);
|
||||
fill: var(--color-layer-2);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
svg:hover circle {
|
||||
opacity: 1;
|
||||
}
|
||||
circle.active,
|
||||
circle:hover {
|
||||
fill: var(--color-layer-3);
|
||||
}
|
||||
</style>
|
||||
26
packages/ui/src/lib/inputs/InputVec3.svelte.ts
Normal file
26
packages/ui/src/lib/inputs/InputVec3.svelte.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import InputVec3 from './InputVec3.svelte';
|
||||
|
||||
describe('InputVec3', () => {
|
||||
it('should render with correct initial values', async () => {
|
||||
const value = $state([1.5, 2.5, 3.5]);
|
||||
render(InputVec3, { value });
|
||||
|
||||
const inputs = page.getByRole('spinbutton');
|
||||
await expect.element(inputs.first()).toBeInTheDocument();
|
||||
|
||||
expect(inputs.nth(0)).toHaveValue(1.5);
|
||||
expect(inputs.nth(1)).toHaveValue(2.5);
|
||||
expect(inputs.nth(2)).toHaveValue(3.5);
|
||||
});
|
||||
|
||||
it('should have step attribute', async () => {
|
||||
const value = $state([0, 0, 0]);
|
||||
render(InputVec3, { value });
|
||||
|
||||
const input = page.getByRole('spinbutton').first();
|
||||
await expect.element(input).toHaveAttribute('step', '0.01');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
<script lang="ts">
|
||||
import '$lib/app.css';
|
||||
import { Details, InputCheckbox, InputNumber, InputSelect, InputVec3, ShortCut } from '$lib';
|
||||
import {
|
||||
Details,
|
||||
InputCheckbox,
|
||||
InputColor,
|
||||
InputNumber,
|
||||
InputSelect,
|
||||
InputShape,
|
||||
InputVec3,
|
||||
ShortCut
|
||||
} from '$lib';
|
||||
import Section from './Section.svelte';
|
||||
import Theme from './Theme.svelte';
|
||||
import ThemeSelector from './ThemeSelector.svelte';
|
||||
|
||||
let intValue = $state(0);
|
||||
let floatValue = $state(0.2);
|
||||
@@ -10,61 +21,23 @@
|
||||
const options = ['strawberry', 'raspberry', 'chickpeas'];
|
||||
let selectValue = $state(0);
|
||||
const d = $derived(options[selectValue]);
|
||||
|
||||
let checked = $state(false);
|
||||
let colorValue = $state<[number, number, number]>([59, 130, 246]);
|
||||
let mirrorShape = $state(true);
|
||||
let detailsOpen = $state(false);
|
||||
|
||||
const themes = [
|
||||
'dark',
|
||||
'light',
|
||||
'solarized',
|
||||
'catppuccin',
|
||||
'high-contrast',
|
||||
'nord',
|
||||
'dracula'
|
||||
];
|
||||
let themeIndex = $state(0);
|
||||
$effect(() => {
|
||||
const classList = document.documentElement.classList;
|
||||
for (const c of classList) {
|
||||
if (c.startsWith('theme-')) document.documentElement.classList.remove(c);
|
||||
}
|
||||
document.documentElement.classList.add(`theme-${themes[themeIndex]}`);
|
||||
});
|
||||
|
||||
const colors = [
|
||||
'layer-0',
|
||||
'layer-1',
|
||||
'layer-2',
|
||||
'layer-3',
|
||||
'active',
|
||||
'selected',
|
||||
'outline',
|
||||
'connection',
|
||||
'edge',
|
||||
'text'
|
||||
];
|
||||
let points = $state([]);
|
||||
let theme = $state('dark');
|
||||
</script>
|
||||
|
||||
<main class="flex flex-col gap-8 py-8">
|
||||
<div class="flex gap-4">
|
||||
<h1 class="text-4xl">@nodarium/ui</h1>
|
||||
<InputSelect bind:value={themeIndex} options={themes}></InputSelect>
|
||||
<ThemeSelector bind:theme />
|
||||
</div>
|
||||
|
||||
<Section title="Colors">
|
||||
<table>
|
||||
<tbody>
|
||||
{#each colors as color (color)}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="w-6 h-6 mr-2 my-1 rounded-sm outline-1 bg-{color}"></div>
|
||||
</td>
|
||||
<td>{color}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Section title="InputNumber">
|
||||
<Theme />
|
||||
</Section>
|
||||
|
||||
<Section title="InputNumber">
|
||||
@@ -90,6 +63,23 @@
|
||||
<InputCheckbox bind:value={checked} />
|
||||
</Section>
|
||||
|
||||
<Section title="Color" value={colorValue}>
|
||||
<InputColor bind:value={colorValue} />
|
||||
</Section>
|
||||
|
||||
<Section title="Shape">
|
||||
{#snippet header()}
|
||||
<label class="flex gap-2">
|
||||
<InputCheckbox bind:value={mirrorShape} />
|
||||
<p>mirror</p>
|
||||
</label>
|
||||
<p>{JSON.stringify(points)}</p>
|
||||
{/snippet}
|
||||
<div style:width="300px">
|
||||
<InputShape bind:value={points} mirror={mirrorShape} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Details" value={detailsOpen}>
|
||||
<Details title="More Information" bind:open={detailsOpen}>
|
||||
<p>Here is some more information that was previously hidden.</p>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte';
|
||||
let { title, value, children, class: _class } = $props<{
|
||||
let { title, value, header, children, class: _class } = $props<{
|
||||
title?: string;
|
||||
value?: unknown;
|
||||
header?: Snippet;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
}>();
|
||||
@@ -11,7 +12,13 @@
|
||||
<section class="border-outline border-1/2 bg-layer-1 rounded border mb-4 p-4 flex flex-col gap-4 {_class}">
|
||||
<h3 class="flex gap-2 font-bold">
|
||||
{title}
|
||||
<p class="font-normal! opacity-50!">{value}</p>
|
||||
<div class="flex gap-4 w-full font-normal opacity-50 max-w-[75%] whitespace-pre overflow-hidden text-clip">
|
||||
{#if header}
|
||||
{@render header()}
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
</div>
|
||||
</h3>
|
||||
<div>
|
||||
{@render children()}
|
||||
|
||||
89
packages/ui/src/routes/Theme.svelte
Normal file
89
packages/ui/src/routes/Theme.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { InputColor } from '$lib';
|
||||
|
||||
const colors = [
|
||||
'layer-0',
|
||||
'layer-1',
|
||||
'layer-2',
|
||||
'layer-3',
|
||||
'active',
|
||||
'selected',
|
||||
'outline',
|
||||
'connection',
|
||||
'text'
|
||||
];
|
||||
|
||||
type CustomColors = {
|
||||
text: [number, number, number];
|
||||
outline: [number, number, number];
|
||||
'layer-0': [number, number, number];
|
||||
'layer-1': [number, number, number];
|
||||
'layer-2': [number, number, number];
|
||||
'layer-3': [number, number, number];
|
||||
active: [number, number, number];
|
||||
selected: [number, number, number];
|
||||
connection: [number, number, number];
|
||||
};
|
||||
|
||||
type CustomColorKey = keyof CustomColors;
|
||||
|
||||
let customColors = $state<CustomColors>({
|
||||
text: [205, 214, 244],
|
||||
outline: [62, 62, 79],
|
||||
'layer-0': [6, 6, 27],
|
||||
'layer-1': [23, 23, 46],
|
||||
'layer-2': [49, 50, 68],
|
||||
'layer-3': [168, 170, 200],
|
||||
active: [0, 0, 0],
|
||||
selected: [38, 139, 210],
|
||||
connection: [131, 148, 150]
|
||||
});
|
||||
|
||||
const themeCss = $derived.by(() => {
|
||||
return `<style>html.theme-custom{
|
||||
${
|
||||
Object.keys(customColors)
|
||||
.map((v) => {
|
||||
return `--color-${v}: rgb(${customColors[v as CustomColorKey].join(',')});`;
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
</style>`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html themeCss}
|
||||
</svelte:head>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Color</th>
|
||||
<th>Name</th>
|
||||
<th>Custom</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each colors as color (color)}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="w-6 h-6 mr-2 my-1 rounded-sm outline-1 bg-{color}"></div>
|
||||
</td>
|
||||
<td>{color}</td>
|
||||
<td>
|
||||
<InputColor bind:value={customColors[color as CustomColorKey]} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
table {
|
||||
border-spacing: 5px;
|
||||
border-collapse: separate;
|
||||
text-align: left;
|
||||
margin-left: 5px;
|
||||
}
|
||||
28
packages/ui/src/routes/ThemeSelector.svelte
Normal file
28
packages/ui/src/routes/ThemeSelector.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { InputSelect } from '$lib';
|
||||
const themes = [
|
||||
'dark',
|
||||
'light',
|
||||
'solarized',
|
||||
'catppuccin',
|
||||
'high-contrast',
|
||||
'high-contrast-light',
|
||||
'nord',
|
||||
'dracula',
|
||||
'custom'
|
||||
];
|
||||
|
||||
let { theme = $bindable() } = $props();
|
||||
|
||||
let themeIndex = $state(0);
|
||||
$effect(() => {
|
||||
theme = themes[themeIndex];
|
||||
const classList = document.documentElement.classList;
|
||||
for (const c of classList) {
|
||||
if (c.startsWith('theme-')) document.documentElement.classList.remove(c);
|
||||
}
|
||||
document.documentElement.classList.add(`theme-${themes[themeIndex]}`);
|
||||
});
|
||||
</script>
|
||||
|
||||
<InputSelect bind:value={themeIndex} options={themes}></InputSelect>
|
||||
@@ -11,7 +11,7 @@ const config = {
|
||||
|
||||
kit: {
|
||||
paths: {
|
||||
base: BASE_URL
|
||||
base: BASE_URL === '/' ? '' : BASE_URL
|
||||
},
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { exec } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
@@ -21,6 +22,31 @@ const postDevPackagePlugin = () => {
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), postDevPackagePlugin()],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
expect: { requireAssertions: true },
|
||||
projects: [
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'client',
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'firefox', headless: true }]
|
||||
},
|
||||
include: ['src/**/*.svelte.ts'],
|
||||
exclude: ['src/lib/server/**']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodarium/utils",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"description": "",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
@@ -12,11 +12,11 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nodarium/types": "workspace:"
|
||||
"@nodarium/types": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dprint": "^0.51.1",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user