1
0
mirror of https://github.com/alexandrev/xslt-lab.git synced 2026-09-13 08:43:16 +00:00

version 0.2.0

This commit is contained in:
2025-11-15 22:26:25 +01:00
parent 39d6767ac5
commit de28a305df
29 changed files with 6201 additions and 540 deletions
+1
View File
@@ -196,5 +196,6 @@ cython_debug/
frontend/node_modules
frontend/dist
backend/server
backend/src/.gocache
/config/credentials.json
credentials.json
+3
View File
@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}
+13
View File
@@ -0,0 +1,13 @@
# Changelog
## v0.2.0
- Expand the transformation up to three independent workspaces, persist each workspace state, and add import/export controls for sharing setups.
- Surface the GitHub Pages news/blog link directly in the app header and show the running UI version with a deep link to this changelog.
- Introduce workspace JSON export/import plus per-workspace trace, error and result retention.
- New look & feel to provide a better experience
## v0.1.0
- Initial release.
+17 -2
View File
@@ -4,16 +4,24 @@ BACKEND_IMAGE=xslt-playground-backend
FRONTEND_IMAGE=xslt-playground-frontend
.PHONY: all backend-build frontend-build backend-image frontend-image compose-up compose-down
.PHONY: all backend-build frontend-build backend-image frontend-image compose-up compose-down clean backend-test frontend-test test
all: backend-build frontend-build backend-image frontend-image compose-up
all: backend-test frontend-test backend-build frontend-build backend-image frontend-image compose-up
backend-build:
cd $(BACKEND_DIR)/src && go mod tidy && go build -o ../server
backend-test:
cd $(BACKEND_DIR)/src && GOCACHE=$$(pwd)/.gocache go test ./...
frontend-build:
cd $(FRONTEND_DIR) && npm install && npm run build
frontend-test:
cd $(FRONTEND_DIR) && npm install && npm run test
test: backend-test frontend-test
backend-image:
docker build --platform linux/amd64 -t $(BACKEND_IMAGE) $(BACKEND_DIR)
docker tag $(BACKEND_IMAGE):latest ghcr.io/alexandrev/$(BACKEND_IMAGE):latest
@@ -27,3 +35,10 @@ compose-up:
compose-down:
docker compose -f docker-compose.local.yml down
clean:
-docker compose -f docker-compose.local.yml down --remove-orphans --rmi local
@if docker image inspect $(BACKEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f $(BACKEND_IMAGE):latest; fi
@if docker image inspect $(FRONTEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f $(FRONTEND_IMAGE):latest; fi
@if docker image inspect ghcr.io/alexandrev/$(BACKEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f ghcr.io/alexandrev/$(BACKEND_IMAGE):latest; fi
@if docker image inspect ghcr.io/alexandrev/$(FRONTEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f ghcr.io/alexandrev/$(FRONTEND_IMAGE):latest; fi
+11 -3
View File
@@ -2,6 +2,11 @@
Your Lab for XSLT Transformation.
## News & Releases
- Follow updates on the GitHub Pages blog: [alexandrev.github.io/xslt-lab](https://alexandrev.github.io/xslt-lab/).
- Review detailed changes in [CHANGELOG.md](https://github.com/alexandrev/xslt-lab/blob/main/CHANGELOG.md).
## Frontend
The React/Vite frontend lives in `frontend/`. Use `npm install` inside that folder and run:
@@ -25,11 +30,15 @@ containerized version the URL is now read at **runtime** from environment
variables so you can configure it directly in the pod.
When `VITE_GO_PRO=true` the UI exposes additional features like Google
authentication and multiple transformation tabs. For authentication you must
authentication. For authentication you must
provide Firebase configuration via `VITE_FIREBASE_CONFIG` containing the JSON
object used by `initializeApp`.
Set `VITE_GA_ID` to enable Google Analytics tracking.
The playground keeps up to three independent workspaces (tabs). Each workspace
persists its own inputs, trace output, errors and results, and you can export or
import them as JSON files to share setups easily.
### Docker
To build a container with the compiled frontend run:
@@ -70,7 +79,7 @@ its dependencies from `/opt/saxon/*`.
The backend image also builds a small jar with custom Saxon extension
functions. It gets copied to `/opt/saxon/custom-functions.jar` during the
Docker build. You can call these from XSLT using the namespace
`xmlns:tib="java:com.xsltplayground.ext.CustomFunctions"`. The jar exposes
`xmlns:tib="http://www.tibco.com/bw/xslt/custom-functions"`. The jar exposes
many helper functions such as `tib:uuid()`, `tib:timestamp()` and
`tib:addToDate()`.
@@ -148,4 +157,3 @@ helm install xslt charts/xslt-playground
# helm install xslt charts/xslt-playground --set firebase.enabled=false
# helm install xslt charts/xslt-playground --set storage.enabled=false
```
+3 -7
View File
@@ -10,19 +10,15 @@ WORKDIR /app/src
# Copiar los archivos
COPY src/ .
RUN rm -rf go.mod go.sum
# Compilar el binario estático
RUN go mod init xslt-playground && \
go get github.com/gin-gonic/gin && \
go mod tidy && \
RUN go mod tidy && \
go build -o server .
# Build extension functions jar
WORKDIR /app/ext
COPY ext/ .
RUN mkdir -p /tmp/saxon && \
curl -L -o /tmp/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/11.6/Saxon-HE-11.6.jar && \
curl -L -o /tmp/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
javac -cp /tmp/saxon/saxon-he.jar com/xsltplayground/ext/CustomFunctions.java com/xsltplayground/Runner.java && \
jar cf custom-functions.jar com
@@ -43,7 +39,7 @@ COPY app.config .
# Crear carpeta para Saxon y descargarlo
RUN mkdir -p /opt/saxon && \
curl -L -o /opt/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/11.6/Saxon-HE-11.6.jar && \
curl -L -o /opt/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
curl -L -o /opt/saxon/xmlresolver.jar https://repo1.maven.org/maven2/org/xmlresolver/xmlresolver/4.5.0/xmlresolver-4.5.0.jar
# Extension functions jar
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,8 @@ import javax.xml.transform.stream.StreamResult;
import org.w3c.dom.Node;
public class CustomFunctions {
private static final String NAMESPACE_URI = "http://www.tibco.com/bw/xslt/custom-functions";
public static String uuid() {
return UUID.randomUUID().toString();
}
@@ -387,12 +389,12 @@ public class CustomFunctions {
// Saxon HE integration
// ---------------------------------------------------------------------
// Saxon-HE doesn't support reflexive java: calls. To keep XSLT unchanged
// (e.g., xmlns:java="java:com.xsltplayground.ext.CustomFunctions" and
// java:uuid()), register integrated extension functions programmatically.
// (e.g., xmlns:tib="http://www.tibco.com/bw/xslt/custom-functions" and
// tib:uuid()), register integrated extension functions programmatically.
// Call CustomFunctions.registerAll(processor) once during initialization.
public static void registerAll(net.sf.saxon.s9api.Processor processor) {
final String namespace = "java:" + CustomFunctions.class.getName();
final String namespace = NAMESPACE_URI;
java.lang.reflect.Method[] methods = CustomFunctions.class.getDeclaredMethods();
for (java.lang.reflect.Method m : methods) {
+59 -47
View File
@@ -270,55 +270,67 @@ func main() {
duration := time.Since(start).Milliseconds()
log.Printf("transformation done in %dms", duration)
var traceEntries []TraceEntry
var traceText string
if req.Trace {
// Load trace text from file (preferred) or stderr
if tracePath != "" {
if data, err := os.ReadFile(tracePath); err == nil {
traceText = string(data)
}
}
if traceText == "" {
traceText = stderr.String()
}
var traceEntries []TraceEntry
var traceText string
if req.Trace {
// Load trace text from file (preferred) or stderr
if tracePath != "" {
if data, err := os.ReadFile(tracePath); err == nil {
traceText = string(data)
log.Printf("trace file %s size=%d bytes", tracePath, len(traceText))
} else {
log.Printf("trace read error: %v", err)
}
}
if traceText == "" {
traceText = stderr.String()
if traceText != "" {
log.Printf("trace fallback from stderr size=%d bytes", len(traceText))
}
}
// Parse block-based variable traces and legacy single-line ones
lines := strings.Split(traceText, "\n")
capturing := false
var currName string
var buf []string
for _, l := range lines {
if strings.HasPrefix(l, "TRACE_VAR_START|") {
capturing = true
currName = strings.TrimPrefix(l, "TRACE_VAR_START|")
buf = nil
continue
}
if strings.HasPrefix(l, "TRACE_VAR_END") {
if capturing {
value := strings.Join(buf, "\n")
traceEntries = append(traceEntries, TraceEntry{Name: currName, Value: value})
}
capturing = false
currName = ""
buf = nil
continue
}
if capturing {
buf = append(buf, l)
continue
}
if strings.HasPrefix(l, "TRACE_VAR|") {
parts := strings.SplitN(l, "|", 3)
if len(parts) == 3 {
traceEntries = append(traceEntries, TraceEntry{Name: parts[1], Value: parts[2]})
}
}
}
}
// Parse block-based variable traces and legacy single-line ones
lines := strings.Split(traceText, "\n")
filtered := make([]string, 0, len(lines))
capturing := false
var currName string
var buf []string
for _, l := range lines {
if strings.HasPrefix(l, "TRACE_DEBUG") {
continue
}
filtered = append(filtered, l)
if strings.HasPrefix(l, "TRACE_VAR_START|") {
capturing = true
currName = strings.TrimPrefix(l, "TRACE_VAR_START|")
buf = nil
continue
}
if strings.HasPrefix(l, "TRACE_VAR_END") {
if capturing {
value := strings.Join(buf, "\n")
traceEntries = append(traceEntries, TraceEntry{Name: currName, Value: value})
}
capturing = false
currName = ""
buf = nil
continue
}
if capturing {
buf = append(buf, l)
continue
}
if strings.HasPrefix(l, "TRACE_VAR|") {
parts := strings.SplitN(l, "|", 3)
if len(parts) == 3 {
traceEntries = append(traceEntries, TraceEntry{Name: parts[1], Value: parts[2]})
}
}
}
traceText = strings.Join(filtered, "\n")
}
c.JSON(http.StatusOK, TransformResponse{Result: string(result), DurationMs: duration, Trace: traceEntries, TraceText: traceText})
c.JSON(http.StatusOK, TransformResponse{Result: string(result), DurationMs: duration, Trace: traceEntries, TraceText: traceText})
})
r.GET("/", func(c *gin.Context) {
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestLoadConfigAppliesEnvOverrides(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://env")
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/creds.json")
t.Setenv("SAXON_CLASSPATH", "env-classpath")
dir := t.TempDir()
cfgPath := filepath.Join(dir, "app.config")
payload := `{
"port": "3000",
"saxon_classpath": "classpath",
"database_url": "postgres://file",
"firebase_credentials": "/tmp/file-creds.json"
}`
if err := os.WriteFile(cfgPath, []byte(payload), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := loadConfig(cfgPath)
if err != nil {
t.Fatalf("loadConfig returned error: %v", err)
}
if cfg.DatabaseURL != "postgres://env" {
t.Fatalf("expected env database url, got %s", cfg.DatabaseURL)
}
if cfg.FirebaseCredentials != "/tmp/creds.json" {
t.Fatalf("expected env firebase creds, got %s", cfg.FirebaseCredentials)
}
if cfg.SaxonClasspath != "env-classpath" {
t.Fatalf("expected env saxon classpath, got %s", cfg.SaxonClasspath)
}
}
func TestCorsMiddlewareSetsHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(corsMiddleware())
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
headers := rec.Result().Header
if headers.Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("missing CORS origin header")
}
if headers.Get("Access-Control-Allow-Methods") == "" {
t.Fatalf("missing CORS methods header")
}
if headers.Get("Access-Control-Allow-Headers") == "" {
t.Fatalf("missing CORS headers header")
}
}
func TestCorsMiddlewareHandlesOptionsRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(corsMiddleware())
handlerCalled := false
router.Any("/test", func(c *gin.Context) {
handlerCalled = true
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodOptions, "/test", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if handlerCalled {
t.Fatalf("handler should not be called for OPTIONS requests")
}
}
+1
View File
@@ -4,6 +4,7 @@ services:
build: ./backend
environment:
VITE_GO_PRO: "false"
XSLT_TRACE_DEBUG: "true"
ports:
- "8000:8000"
frontend:
+19 -6
View File
@@ -1,14 +1,27 @@
#!/bin/sh
set -e
escape_js_string() {
# Escape backslashes, double quotes and newlines so runtime env stays valid JS
printf '%s' "$1" | sed ':a;N;$!ba;s/\\/\\\\/g;s/\n/\\n/g;s/"/\\"/g'
}
BACKEND_URL_ESC=$(escape_js_string "${VITE_BACKEND_URL}")
GO_PRO_ESC=$(escape_js_string "${VITE_GO_PRO}")
ADSENSE_CLIENT_ESC=$(escape_js_string "${VITE_ADSENSE_CLIENT}")
ADSENSE_SLOT_ESC=$(escape_js_string "${VITE_ADSENSE_SLOT}")
FIREBASE_CONFIG_ESC=$(escape_js_string "${VITE_FIREBASE_CONFIG}")
GA_ID_ESC=$(escape_js_string "${VITE_GA_ID}")
# Write runtime environment variables for the frontend
cat <<EOF >/usr/share/nginx/html/env.js
window.env = {
VITE_BACKEND_URL: "${VITE_BACKEND_URL}",
VITE_GO_PRO: "${VITE_GO_PRO}",
VITE_ADSENSE_CLIENT: "${VITE_ADSENSE_CLIENT}",
VITE_ADSENSE_SLOT: "${VITE_ADSENSE_SLOT}",
VITE_FIREBASE_CONFIG: "${VITE_FIREBASE_CONFIG}",
VITE_GA_ID: "${VITE_GA_ID}"
VITE_BACKEND_URL: "${BACKEND_URL_ESC}",
VITE_GO_PRO: "${GO_PRO_ESC}",
VITE_ADSENSE_CLIENT: "${ADSENSE_CLIENT_ESC}",
VITE_ADSENSE_SLOT: "${ADSENSE_SLOT_ESC}",
VITE_FIREBASE_CONFIG: "${FIREBASE_CONFIG_ESC}",
VITE_GA_ID: "${GA_ID_ESC}"
};
EOF
+1
View File
@@ -21,6 +21,7 @@
<meta property="og:url" content="https://xsltplayground.com/" />
<meta property="og:image" content="/logo.svg" />
<link rel="canonical" href="https://xsltplayground.com/" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script type="module" src="/env.js"></script>
<script>
if (window.env && window.env.VITE_ADSENSE_CLIENT) {
+1455 -6
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,11 +1,12 @@
{
"name": "xslt-playground",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -17,7 +18,11 @@
"xml-formatter": "^3.6.6"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"jsdom": "^25.0.1",
"@vitejs/plugin-react": "^4.1.0",
"vite": "^5.0.0"
"vite": "^5.0.0",
"vitest": "^2.1.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+1240 -316
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
vi.mock("@monaco-editor/react", () => ({
default: () => <div data-testid="monaco-editor" />,
}));
vi.mock("ga-4-react", () => {
return {
default: class {
initialize() {
return Promise.resolve();
}
},
};
});
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
json: async () => ({ result: "<root/>", duration_ms: 5 }),
}),
),
);
window.env = {
VITE_BACKEND_URL: "",
VITE_GA_ID: "",
VITE_GO_PRO: "false",
VITE_APP_VERSION: "test",
VITE_NEWS_URL: "https://example.com/news",
VITE_REPO_URL: "https://example.com/repo",
};
window.adsbygoogle = [];
localStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
delete window.env;
});
describe("App bootstrap", () => {
it("renders without crashing", async () => {
render(<App />);
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(screen.getByText(/xsltplayground\.com/i)).toBeInTheDocument();
});
});
-31
View File
@@ -1,31 +0,0 @@
import React, { useEffect } from "react";
export default function Buymeacoffee() {
useEffect(() => {
const script = document.createElement("script");
const div = document.getElementById("supportByBMC");
script.setAttribute("data-name", "BMC-Widget");
script.src = "https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js";
script.setAttribute("data-id", "alexandrev");
script.setAttribute("data-description", "Support me on Buy me a coffee!");
script.setAttribute(
"data-message",
"Thank you for visiting my website. If this app has helped you in anyway, consider buying us a coffee. ✨😎",
);
script.setAttribute("data-color", "#FFDD00");
script.setAttribute("data-position", "Right");
script.setAttribute("data-x_margin", "18");
script.setAttribute("data-y_margin", "18");
script.async = true;
document.head.appendChild(script);
script.onload = function () {
var evt = document.createEvent("Event");
evt.initEvent("DOMContentLoaded", false, false);
window.dispatchEvent(evt);
};
div.appendChild(script);
}, []);
return <div id="supportByBMC"></div>;
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
const SCRIPT_ID = "bmc-widget";
export default function BuyMeACoffee() {
const containerRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
if (document.getElementById(SCRIPT_ID)) return;
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.setAttribute("data-name", "BMC-Widget");
script.src = "https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js";
script.setAttribute("data-id", "alexandrev");
script.setAttribute("data-description", "Support me on Buy me a coffee!");
script.setAttribute(
"data-message",
"Thanks for using XSLT Playground. If it helped you, feel free to buy me a coffee ☕",
);
script.setAttribute("data-color", "#FFDD00");
script.setAttribute("data-position", "Right");
script.setAttribute("data-x_margin", "24");
script.setAttribute("data-y_margin", "24");
script.async = true;
containerRef.current.appendChild(script);
const handleLoad = () => {
const evt = document.createEvent("Event");
evt.initEvent("DOMContentLoaded", false, false);
window.dispatchEvent(evt);
};
script.addEventListener("load", handleLoad);
return () => {
script.removeEventListener("load", handleLoad);
};
}, []);
return <div id="supportByBMC" ref={containerRef} />;
}
@@ -0,0 +1,31 @@
export default function DataPipelineHeader({
collapsed,
onToggleCollapsed,
onAddParam,
}) {
return (
<div className="params-header">
<button
type="button"
className="icon-button params-collapse"
title={collapsed ? "Show data pipeline" : "Hide data pipeline"}
onClick={onToggleCollapsed}
aria-label={collapsed ? "Show data pipeline" : "Hide data pipeline"}
>
{collapsed ? "▶" : "▼"}
</button>
<div className="title">Data Pipeline</div>
<div className="params-header-actions">
<button
type="button"
className="icon-button"
onClick={onAddParam}
title="Add new parameter"
aria-label="Add new parameter"
>
</button>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import DataPipelineHeader from "./DataPipelineHeader";
afterEach(() => {
cleanup();
});
describe("DataPipelineHeader", () => {
it("calls toggle when collapse button clicked", () => {
const onToggle = vi.fn();
render(
<DataPipelineHeader
collapsed={false}
onToggleCollapsed={onToggle}
onAddParam={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /hide data pipeline/i }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("calls add handler when add button pressed", () => {
const onAdd = vi.fn();
render(
<DataPipelineHeader
collapsed
onToggleCollapsed={vi.fn()}
onAddParam={onAdd}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /add new parameter/i }));
expect(onAdd).toHaveBeenCalledTimes(1);
});
});
+249
View File
@@ -0,0 +1,249 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
const FEEDBACK_MAIL = "xsltplayground@alexandre-vazquez.cloud";
const STORAGE_KEY = "feedbackPos";
const MIN_MARGIN = 10;
const DEFAULT_MARGIN = 24;
const FALLBACK_WIDTH = 220;
export default function FeedbackWidget() {
const [collapsed, setCollapsed] = useState(() => {
try {
return JSON.parse(localStorage.getItem("feedbackCollapsed") || "false");
} catch {
return false;
}
});
const widgetRef = useRef(null);
const [viewportHeight, setViewportHeight] = useState(() =>
typeof window !== "undefined" ? window.innerHeight : 0,
);
const [viewportWidth, setViewportWidth] = useState(() =>
typeof window !== "undefined" ? window.innerWidth : 0,
);
const [position, setPosition] = useState(() => {
if (typeof window === "undefined") return null;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (
typeof parsed?.x === "number" &&
typeof parsed?.y === "number"
) {
return parsed;
}
if (
typeof parsed?.right === "number" &&
typeof parsed?.top === "number"
) {
return {
x: Math.max(
MIN_MARGIN,
window.innerWidth - parsed.right - FALLBACK_WIDTH,
),
y: Math.max(MIN_MARGIN, parsed.top),
};
}
}
} catch {}
return null;
});
const [dragging, setDragging] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [opensUp, setOpensUp] = useState(false);
const resolvedPosition = useMemo(
() => {
if (position) return position;
const fallbackHeight =
viewportHeight || (typeof window !== "undefined" ? window.innerHeight : 0);
return {
x: DEFAULT_MARGIN,
y: Math.max(DEFAULT_MARGIN, fallbackHeight - 220),
};
},
[position, viewportHeight],
);
useEffect(() => {
if (typeof window === "undefined") return;
const handleResize = () => {
setViewportHeight(window.innerHeight);
setViewportWidth(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
try {
localStorage.setItem("feedbackCollapsed", JSON.stringify(collapsed));
} catch {}
}, [collapsed]);
const clampWithinViewport = useCallback(
(pos) => {
if (
!pos ||
typeof window === "undefined" ||
!widgetRef.current
) {
return pos;
}
const rect = widgetRef.current.getBoundingClientRect();
const maxX = Math.max(
MIN_MARGIN,
window.innerWidth - rect.width - MIN_MARGIN,
);
const maxY = Math.max(
MIN_MARGIN,
window.innerHeight - rect.height - MIN_MARGIN,
);
return {
x: Math.min(Math.max(MIN_MARGIN, pos.x), maxX),
y: Math.min(Math.max(MIN_MARGIN, pos.y), maxY),
};
},
[],
);
useLayoutEffect(() => {
if (!position) return;
const next = clampWithinViewport(position);
if (!next) return;
if (next.x !== position.x || next.y !== position.y) {
setPosition(next);
}
}, [position, clampWithinViewport, viewportHeight, viewportWidth, collapsed]);
useLayoutEffect(() => {
if (
position ||
typeof window === "undefined" ||
typeof document === "undefined"
)
return;
const widget = widgetRef.current;
if (!widget) return;
const footer = document.querySelector(".footer");
const margin = DEFAULT_MARGIN;
const viewport = window.innerHeight;
const widgetRect = widget.getBoundingClientRect();
let y = Math.max(margin, viewport - widgetRect.height - margin);
if (footer) {
const footerRect = footer.getBoundingClientRect();
if (footerRect.top < viewport) {
y = Math.max(margin, footerRect.top - widgetRect.height - margin);
}
}
setPosition({ x: margin, y });
}, [position]);
useEffect(() => {
if (!dragging) return;
const handleMove = (event) => {
event.preventDefault();
const widget = widgetRef.current;
const widgetRect = widget?.getBoundingClientRect();
const maxX = Math.max(
MIN_MARGIN,
window.innerWidth -
(widgetRect?.width ?? 0) -
MIN_MARGIN,
);
const maxY = Math.max(
MIN_MARGIN,
window.innerHeight -
(widgetRect?.height ?? 0) -
MIN_MARGIN,
);
setPosition({
x: Math.min(
Math.max(MIN_MARGIN, event.clientX - offset.x),
maxX,
),
y: Math.min(
Math.max(MIN_MARGIN, event.clientY - offset.y),
maxY,
),
});
};
const stop = () => setDragging(false);
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", stop);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", stop);
};
}, [dragging, offset]);
useEffect(() => {
if (dragging || !position) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(position));
} catch {}
}, [dragging, position]);
useLayoutEffect(() => {
if (!widgetRef.current || !viewportHeight) return;
const rect = widgetRef.current.getBoundingClientRect();
setOpensUp(rect.top + rect.height / 2 > viewportHeight / 2);
}, [position, collapsed, viewportHeight]);
const startDrag = (event) => {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
setOffset({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
setDragging(true);
};
const mailLink = `mailto:${FEEDBACK_MAIL}?subject=${encodeURIComponent(
"xsltplayground feedback",
)}`;
return (
<div
ref={widgetRef}
className={`feedback-widget ${collapsed ? "collapsed" : ""} ${opensUp ? "opens-up" : ""}`}
style={{ left: resolvedPosition.x, top: resolvedPosition.y }}
>
<div
className="feedback-header"
onMouseDown={startDrag}
role="button"
tabIndex={0}
>
<span>Feedback</span>
<button
type="button"
className="icon-button"
onClick={() => setCollapsed((prev) => !prev)}
aria-label={collapsed ? "Show feedback panel" : "Hide feedback panel"}
>
{collapsed ? "▲" : "▼"}
</button>
</div>
{!collapsed && (
<div className="feedback-body">
<p>Have an idea or found a glitch? I would love to hear from you.</p>
<a className="feedback-link" href={mailLink}>
Send feedback
</a>
</div>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
export default function TabsNav({
tabs,
activeId,
onSelect,
onClose,
onExport,
onClear,
}) {
return (
<div className="tabs-left">
{tabs.map((tab, index) => {
const isActive = tab.id === activeId;
return (
<div
key={tab.id}
className={`tab ${isActive ? "active" : ""}`}
>
<div className="tab-tools">
<button
type="button"
className="tab-icon"
onClick={() => onExport?.(tab)}
title="Export workspace"
aria-label={`Export workspace ${index + 1}`}
>
📤
</button>
<button
type="button"
className="tab-icon"
onClick={() => onClear?.(tab)}
title="Clear workspace"
aria-label={`Clear workspace ${index + 1}`}
>
🧹
</button>
</div>
<button
type="button"
className="tab-button"
onClick={() => onSelect?.(tab.id)}
>
{`Workspace ${index + 1}`}
</button>
{tabs.length > 1 && (
<button
type="button"
className="tab-close"
onClick={(e) => {
e.stopPropagation();
onClose?.(tab.id);
}}
title="Close workspace"
aria-label={`Close workspace ${index + 1}`}
>
</button>
)}
</div>
);
})}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import TabsNav from "./TabsNav";
const sampleTabs = [
{ id: "a" },
{ id: "b" },
];
afterEach(() => {
cleanup();
});
describe("TabsNav", () => {
it("invokes export handler for the selected tab button", () => {
const onExport = vi.fn();
render(
<TabsNav
tabs={sampleTabs}
activeId="a"
onSelect={vi.fn()}
onClose={vi.fn()}
onExport={onExport}
onClear={vi.fn()}
/>,
);
const exportButtons = screen.getAllByRole("button", { name: /Export workspace/ });
fireEvent.click(exportButtons[1]);
expect(onExport).toHaveBeenCalledTimes(1);
expect(onExport).toHaveBeenCalledWith(sampleTabs[1]);
});
it("invokes clear handler for matching tab", () => {
const onClear = vi.fn();
render(
<TabsNav
tabs={sampleTabs}
activeId="a"
onSelect={vi.fn()}
onClose={vi.fn()}
onExport={vi.fn()}
onClear={onClear}
/>,
);
const clearButton = screen.getAllByRole("button", { name: /Clear workspace/ })[0];
fireEvent.click(clearButton);
expect(onClear).toHaveBeenCalledTimes(1);
expect(onClear).toHaveBeenCalledWith(sampleTabs[0]);
});
});
+27 -3
View File
@@ -1,4 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<rect width="40" height="40" rx="6" fill="#007acc"/>
<text x="20" y="26" text-anchor="middle" font-size="20" font-family="Arial" fill="white" font-weight="bold">XSL</text>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<defs>
<linearGradient id="logo-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#07162f" />
<stop offset="50%" stop-color="#0c3264" />
<stop offset="100%" stop-color="#1480ff" />
</linearGradient>
<linearGradient id="logo-flow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffe07d" />
<stop offset="100%" stop-color="#52f2c5" />
</linearGradient>
</defs>
<rect width="48" height="48" rx="10" fill="url(#logo-bg)" />
<g stroke="#8edfff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.92">
<path d="M15 10 L8.5 18 L15 26" />
<path d="M15 22 L8.5 30 L15 38" />
<path d="M33 10 L39.5 18 L33 26" />
<path d="M33 22 L39.5 30 L33 38" />
</g>
<path d="M7 14h34" stroke="rgba(255,255,255,0.25)" stroke-width="1" />
<path d="M7 34h34" stroke="rgba(255,255,255,0.25)" stroke-width="1" />
<path d="M18 15 L27 15 L35 24 L27 33 L18 33 L23 27 L17 27 L13 24 L17 21 L23 21 Z" fill="url(#logo-flow)" />
<path d="M18 15 L27 15 L35 24 L27 33 L18 33 L23 27 L17 27 L13 24 L17 21 L23 21 Z" fill="none" stroke="rgba(0, 0, 0, 0.3)" stroke-width="0.8" stroke-linejoin="round" />
<path d="M23 18 V30" stroke="#fff3d4" stroke-width="1.5" stroke-linecap="round" />
<circle cx="23" cy="18" r="1.5" fill="#fffcf1" />
<circle cx="23" cy="30" r="1.5" fill="#fffcf1" />
<circle cx="23" cy="24" r="2.3" fill="#051c38" opacity="0.45" />
</svg>

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+749 -56
View File
@@ -7,13 +7,27 @@ body,
background: #f5f5f5;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: white;
border-bottom: 1px solid #ddd;
.news-link {
color: #007acc;
text-decoration: none;
font-weight: 500;
}
.news-link:hover {
text-decoration: underline;
}
.version-pill {
border: 1px solid #007acc;
color: #007acc;
border-radius: 999px;
padding: 0.1rem 0.5rem;
font-size: 0.75rem;
text-decoration: none;
}
.version-pill:hover {
background: #e6f2fb;
}
.logo {
@@ -23,30 +37,110 @@ body,
.tabs {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0 0.5rem;
border-bottom: 1px solid #ddd;
background: #fafafa;
flex-wrap: wrap;
}
.tabs button {
padding: 0.25rem 0.5rem;
.tabs-left {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: flex-end;
flex: 1 1 auto;
}
.tabs-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab {
display: inline-flex;
align-items: center;
border: 1px solid transparent;
border-bottom: none;
border-radius: 6px 6px 0 0;
background: transparent;
border: none;
cursor: pointer;
gap: 0.25rem;
}
.tabs button.active {
border-bottom: 2px solid #007acc;
font-weight: bold;
.tab.active {
background: #fff;
border-color: #ddd;
border-bottom: 1px solid #fff;
}
.tab-button {
padding: 0.4rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
font-weight: 500;
}
.tab.active .tab-button {
color: #007acc;
}
.tab-close {
border: none;
background: transparent;
cursor: pointer;
padding: 0 0.4rem;
font-size: 0.85rem;
color: #777;
}
.tab-close:hover {
color: #c00;
}
.tab-add {
border: 1px dashed #bbb;
border-bottom: none;
border-radius: 6px 6px 0 0;
padding: 0.35rem 0.5rem;
}
.tab-import {
border: 1px dashed #bbb;
border-bottom: none;
border-radius: 6px 6px 0 0;
padding: 0.35rem 0.5rem;
}
.tab-tools {
display: inline-flex;
gap: 0.25rem;
align-items: center;
}
.tab-icon {
border: none;
background: transparent;
cursor: pointer;
padding: 0.25rem;
font-size: 0.85rem;
color: #666;
}
.tab-icon:hover,
.tab.active .tab-icon {
color: #007acc;
}
.toggle {
display: flex;
align-items: center;
}
.toggle button,
.toggle select {
margin-right: 0.25rem;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.toggle .right-actions {
@@ -55,20 +149,94 @@ body,
align-items: center;
}
.trace-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: 0.5rem;
padding: 0.2rem 0.8rem 0.2rem 0.45rem;
border: 1px solid #cfdcf4;
border-radius: 999px;
background: #f0f6ff;
font-size: 0.85rem;
cursor: pointer;
position: relative;
user-select: none;
}
.trace-toggle input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.trace-toggle-box {
width: 1.2rem;
height: 1.2rem;
border-radius: 6px;
border: 2px solid #4a85ff;
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
color: transparent;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.trace-toggle-box::after {
content: "✔";
font-size: 0.8rem;
line-height: 1;
}
.trace-toggle input:checked + .trace-toggle-box {
background: #4a85ff;
color: #fff;
border-color: #4a85ff;
}
.trace-toggle input:focus-visible + .trace-toggle-box {
box-shadow: 0 0 0 2px rgba(74, 133, 255, 0.2);
}
.trace-toggle-label {
white-space: nowrap;
font-weight: 600;
color: #21426c;
}
.error-box {
background: #fee;
color: #900;
padding: 0.5rem;
border: 1px solid #f3c2c2;
border-left: 4px solid #d00;
max-height: 30vh; /* limit height */
overflow: auto; /* enable scroll when needed */
max-height: 7rem;
overflow: auto;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.8rem;
line-height: 1.2;
}
.error-box-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
font-weight: bold;
gap: 0.25rem;
}
.error-box-actions {
display: inline-flex;
gap: 0.25rem;
}
/* Tabular error list */
.error-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.error-row {
@@ -76,21 +244,40 @@ body,
}
.error-icon {
width: 1.75rem;
width: 1.5rem;
text-align: center;
padding-right: 0.25rem;
color: #d00;
}
.error-text {
white-space: pre-wrap;
word-break: break-word;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
max-width: 100%;
padding-right: 0.25rem;
}
.error-line {
display: flex;
align-items: flex-start;
align-items: center;
gap: 0.25rem;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.8rem;
width: 100%;
}
.error-more {
margin-top: 0.25rem;
font-size: 0.7rem;
color: #b55;
}
.error-expand-button {
position: absolute;
top: 0.25rem;
left: 0.25rem;
}
.success-box {
@@ -110,40 +297,254 @@ body,
flex: 1 1 auto;
overflow: hidden;
min-height: 0;
gap: 0;
align-items: stretch;
}
.params {
width: 30%;
overflow-y: auto;
flex: 0 0 auto;
width: 320px;
min-width: 220px;
border-right: 1px solid #ddd;
padding: 0.5rem;
background: #fff;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.params-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 0.65rem;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
background: linear-gradient(180deg, #f9fbff 0%, #f1f4ff 100%);
}
.param-card {
border: 1px solid #dfe5fb;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f6f8ff 100%);
box-shadow: 0 8px 22px rgba(15, 40, 94, 0.07);
padding: 0.55rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.param-card.open {
border-color: #c4d4ff;
box-shadow: 0 14px 34px rgba(15, 40, 94, 0.12);
transform: translateY(-1px);
}
.param-header-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.param-name-wrap {
flex: 1;
display: flex;
align-items: stretch;
border: 1px solid #d5dff7;
border-radius: 999px;
background: #f0f5ff;
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.param-name-wrap:focus-within {
border-color: #4a85ff;
background: #fff;
box-shadow: 0 0 0 2px rgba(74, 133, 255, 0.15);
}
.param-toggle {
flex: 0 0 2.2rem;
border: none;
border-right: 1px solid #d5dff7;
background: transparent;
color: #1f3a63;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease;
}
.param-toggle.open {
color: #21426c;
background: #e1e9ff;
}
.param-toggle:hover {
color: #4a85ff;
}
.param-name-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0.25rem 0.85rem;
font-weight: 600;
font-size: 0.9rem;
color: #1d3770;
}
.param-name-input::placeholder {
color: #6a7da8;
}
.param-name-input:focus {
outline: none;
}
.param-remove {
width: 1.75rem;
height: 1.75rem;
padding: 0;
border-radius: 50%;
border: 1px solid #f8bcbc !important;
background: #fff5f5;
color: #b03a3a;
font-size: 0.85rem;
box-shadow: 0 2px 6px rgba(176, 58, 58, 0.15);
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.param-remove:hover {
background: #ffe4e4;
border-color: #f19999 !important;
color: #9b2c2c;
}
.param-content {
border-top: 1px solid #ecf0fb;
padding-top: 0.45rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.param-editor {
border: 1px solid #dfe5fb;
border-radius: 12px;
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(15, 40, 94, 0.08);
background: #ffffff;
}
.param-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
}
.param-footer .icon-button {
border-radius: 999px;
border: 1px solid #dfe5fb;
background: #f7f9ff;
padding: 0.3rem 0.65rem;
box-shadow: 0 4px 10px rgba(15, 40, 94, 0.08);
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.param-footer .icon-button:hover {
background: #eaf0ff;
border-color: #c7d6ff;
}
.param-upload {
gap: 0.25rem;
}
.params-header {
position: relative;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid #eee;
background: #f7f9fc;
position: sticky;
top: 0;
z-index: 1;
}
.params-header .title {
width: 100%;
text-align: center;
flex: 1;
font-weight: bold;
}
.params-header button {
position: absolute;
right: 0;
top: 0;
.params-header-actions {
display: flex;
gap: 0.25rem;
}
.params-collapse {
padding: 0.25rem;
}
.params-collapsed {
flex: 0 0 2.5rem;
border-right: 1px solid #ddd;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 0.5rem 0.25rem;
background: #fafafa;
margin-right: 0.5rem;
}
.drop-hint {
text-align: center;
color: #666;
margin-top: 0.5rem;
color: #536084;
background: rgba(255, 255, 255, 0.7);
border: 1px dashed #c4d4ff;
border-radius: 12px;
padding: 0.75rem;
font-size: 0.85rem;
font-weight: 500;
}
.pane-divider {
flex: 0 0 auto;
width: 12px;
margin: 0 0.35rem;
cursor: col-resize;
position: relative;
display: flex;
align-items: center;
align-self: stretch;
touch-action: none;
}
.pane-divider span {
width: 4px;
height: 60%;
margin: 0 auto;
border-radius: 999px;
background: rgba(33, 66, 108, 0.2);
transition: background 0.2s ease;
}
.pane-divider:hover span,
.pane-divider.dragging span {
background: #4a85ff;
}
.editor {
width: 70%;
flex: 1 1 auto;
width: auto;
padding: 0.5rem;
overflow: hidden;
height: 100%;
@@ -168,7 +569,10 @@ body,
width: 30%;
background: #fff;
border: 1px solid #ddd;
overflow: auto;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.trace-header {
@@ -176,17 +580,42 @@ body,
border-bottom: 1px solid #eee;
font-weight: bold;
background: #fafafa;
justify-content: space-between;
}
.trace-header-actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.trace-content {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.25rem;
overflow: hidden;
min-height: 0;
}
.trace-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.trace-table-wrap {
flex: 1 1 auto;
position: relative;
overflow: auto;
min-height: 0;
}
.trace-name {
width: 30%;
width: auto;
vertical-align: top;
padding: 0.25rem;
font-family: monospace;
color: #333;
border-right: 1px solid #f0f0f0;
@@ -194,31 +623,179 @@ body,
.trace-value {
vertical-align: top;
padding: 0.25rem;
white-space: pre-wrap;
word-break: break-word;
white-space: normal;
word-break: normal;
font-family: monospace;
}
.trace-cell {
position: relative;
padding: 0.25rem;
}
.trace-preview {
margin: 0;
white-space: pre;
word-break: normal;
overflow: auto;
max-height: 6rem;
}
.trace-name-preview {
max-height: 4.5rem;
}
.trace-value-preview {
max-height: 9rem;
}
.trace-hover-tooltip {
position: fixed;
z-index: 1000;
padding: 0.75rem 0.75rem 0.5rem;
background: rgba(30, 30, 30, 0.95);
color: #f6f6f6;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
max-height: 60vh;
overflow: auto;
white-space: pre-wrap;
font-family: monospace;
font-size: 0.85rem;
backdrop-filter: blur(2px);
}
.trace-hover-tooltip pre {
margin: 0;
white-space: pre-wrap;
}
.trace-hover-actions {
display: flex;
justify-content: flex-end;
gap: 0.25rem;
margin-bottom: 0.35rem;
}
.trace-hover-tooltip .icon-button {
color: #f6f6f6;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 0.2rem 0.35rem;
}
.trace-hover-tooltip .icon-button:hover {
background: rgba(255, 255, 255, 0.15);
}
.trace-raw-block {
flex: 1 1 auto;
margin: 0;
padding: 0.5rem;
background: #f7f7f9;
border: 1px solid #e2e2e4;
border-radius: 4px;
white-space: pre;
font-family: monospace;
font-size: 0.85rem;
overflow: auto;
min-height: 6rem;
}
.trace-empty {
padding: 0.5rem;
color: #777;
font-style: italic;
}
.trace-divider {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
background: transparent;
}
.trace-divider::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(0, 0, 0, 0.1);
transform: translateX(-50%);
}
.trace-divider:hover::after {
background: rgba(0, 122, 204, 0.6);
width: 2px;
}
.result {
height: 40vh;
border-top: 1px solid #ddd;
background: #fff;
padding: 1.75rem 0.5rem 0.75rem;
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
flex: 0 0 auto;
}
.banner {
text-align: center;
padding: 0.5rem;
background: #f0f0f0;
border-top: 1px solid #ddd;
.result-editor-wrap {
flex: 1 1 auto;
min-height: 0;
}
.result-resizer {
flex: 0 0 auto;
height: 0.75rem;
cursor: row-resize;
display: flex;
align-items: center;
justify-content: center;
background: #f6f8fb;
border-top: 1px solid #e1e7f0;
border-bottom: 1px solid #dfe5ef;
}
.result-resizer span {
width: 3rem;
height: 3px;
border-radius: 999px;
background: #c2ccdc;
}
.result-resizer.dragging {
background: #e0ecfc;
}
.result-resizer.dragging span {
background: #007acc;
}
.footer {
text-align: center;
padding: 0.5rem;
background: #fafafa;
border-top: 1px solid #ddd;
font-size: 0.85rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.footer-left,
.footer-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.icon-button {
@@ -229,6 +806,40 @@ body,
font-size: 1rem;
}
.icon-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.version-select {
appearance: none;
-webkit-appearance: none;
border: 1px solid #cfdcf4;
border-radius: 999px;
padding: 0.35rem 1.9rem 0.35rem 0.85rem;
font-weight: 600;
font-size: 0.9rem;
background-color: #f0f6ff;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2321426C' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-position: calc(100% - 0.95rem) center;
background-repeat: no-repeat;
background-size: 12px 8px;
color: #21426c;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.version-select:focus {
outline: none;
border-color: #4a85ff;
box-shadow: 0 0 0 3px rgba(74, 133, 255, 0.15);
background-color: #fff;
}
.version-select:hover {
border-color: #adc5ff;
}
.file-input {
display: none;
}
@@ -238,8 +849,90 @@ body,
align-items: center;
}
.result-format-button {
.result-format-button,
.result-reset-button {
position: absolute;
top: 0;
right: 0;
top: 0.25rem;
}
.result-format-button {
right: 0.5rem;
}
.result-reset-button {
right: 3rem;
transition: color 0.2s ease;
}
.result-reset-button.active {
color: #007acc;
}
.feedback-widget {
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
width: 220px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #ddd;
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(6px);
font-size: 0.9rem;
color: #333;
overflow: hidden;
}
.feedback-widget.opens-up {
flex-direction: column-reverse;
}
.feedback-widget.collapsed {
width: auto;
min-width: 160px;
}
.feedback-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #007acc;
color: white;
padding: 0.35rem 0.5rem;
border-radius: 10px 10px 0 0;
cursor: grab;
font-weight: bold;
}
.feedback-widget.opens-up .feedback-header {
border-radius: 0 0 10px 10px;
}
.feedback-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.feedback-body p {
margin: 0;
font-size: 0.85rem;
color: #555;
}
.feedback-link {
text-decoration: none;
padding: 0.4rem 0.6rem;
text-align: center;
border-radius: 6px;
background: #fff1c1;
color: #805d00;
border: 1px solid #ffd970;
font-weight: 600;
}
.feedback-link:hover {
background: #ffe189;
}
+12
View File
@@ -1,9 +1,21 @@
import { readFileSync } from "fs";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const pkg = JSON.parse(
readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
);
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
test: {
environment: "jsdom",
setupFiles: "./src/setupTests.js",
},
});