chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
[package]
|
||||
name = "perspective-viewer"
|
||||
version = "4.5.2"
|
||||
authors = ["Andrew Stein <steinlink@gmail.com>"]
|
||||
edition = "2024"
|
||||
description = "A data visualization and analytics component, especially well-suited for large and/or streaming datasets."
|
||||
repository = "https://github.com/perspective-dev/perspective"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://perspective-dev.github.io"
|
||||
keywords = ["experimental"]
|
||||
build = "build.rs"
|
||||
include = ["build.rs", "src/**/*", "Cargo.toml", "package.json", "docs/**/*"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
rustc-args = ["--cfg", "web_sys_unstable_apis"]
|
||||
rustdoc-args = ["--html-in-header", "docs/index.html"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
path = "src/rust/lib.rs"
|
||||
|
||||
[features]
|
||||
external-bootstrap = []
|
||||
metadata = []
|
||||
default = []
|
||||
talc-allocator = ["perspective-js/talc-allocator"]
|
||||
trace-allocator = ["perspective-js/trace-allocator"]
|
||||
|
||||
[build-dependencies]
|
||||
lightningcss = "1.0.0-alpha.71"
|
||||
glob = "0.3.0"
|
||||
anyhow = "1.0.66"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.13"
|
||||
|
||||
[dependencies]
|
||||
perspective-client = { version = "4.5.2" }
|
||||
perspective-js = { version = "4.5.2" }
|
||||
|
||||
# Provides async `Mutex` for locked sections such as `render`
|
||||
async-lock = "2.5.0"
|
||||
|
||||
# Encode HTML export
|
||||
base64 = "0.22.1"
|
||||
|
||||
console_error_panic_hook = "0.1.6"
|
||||
|
||||
# Timezone correction
|
||||
chrono = "0.4"
|
||||
|
||||
# More derivable traits
|
||||
derivative = "2.2.0"
|
||||
|
||||
# Easy way to define & implement a singleton trait (type extension)
|
||||
extend = "1.1.2"
|
||||
|
||||
# General async tools
|
||||
futures = "0.3.31"
|
||||
|
||||
# General iterator improvements
|
||||
itertools = "0.14.0"
|
||||
|
||||
# JavaScript stdlib bindings
|
||||
js-sys = "0.3.85"
|
||||
|
||||
macro_rules_attribute = "0.2.2"
|
||||
|
||||
# Parse ExprTK for syntax highlighting.
|
||||
nom = "7.1.1"
|
||||
|
||||
# Serialization for tokens and JS APIs
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
# Support for "unknown"/dictionary types such as `plugin_config`
|
||||
serde_json = { version = "1.0.107", features = ["raw_value"] }
|
||||
|
||||
# Faster struct serialize/deserialize
|
||||
serde-wasm-bindgen = "0.6.0"
|
||||
|
||||
strum = { version = "0.26.1", features = ["derive"] }
|
||||
|
||||
thiserror = { version = "1.0.55" }
|
||||
|
||||
# specta = { version = "2.0.0-rc.9", features = ["typescript"] }
|
||||
ts-rs = { version = "11.1.0", features = [
|
||||
"serde-json-impl",
|
||||
"no-serde-warnings",
|
||||
] }
|
||||
|
||||
# Async-aware logging that can be disabled at compile-time.
|
||||
tracing = { version = ">=0.1.36" }
|
||||
tracing-subscriber = "0.3.15"
|
||||
|
||||
# Browser API bindings
|
||||
wasm-bindgen = { version = "=0.2.108", features = ["enable-interning"] }
|
||||
|
||||
# Pass perspective-js arguments to viewer
|
||||
wasm-bindgen-derive = "0.3.0"
|
||||
|
||||
# Browser `Promise` bindings
|
||||
wasm-bindgen-futures = "0.4.41"
|
||||
|
||||
# Web framework
|
||||
yew = { version = "0.22.0", features = ["csr"] }
|
||||
|
||||
# Browser stdlib bindings
|
||||
web-sys.version = "0.3.85"
|
||||
|
||||
# Browser stdlib bindings
|
||||
web-sys.features = [
|
||||
"Blob",
|
||||
"Clipboard",
|
||||
"CssStyleDeclaration",
|
||||
"CssStyleSheet",
|
||||
"CssRuleList",
|
||||
"CssRule",
|
||||
"CssStyleRule",
|
||||
"CustomEvent",
|
||||
"CustomEventInit",
|
||||
"DataTransfer",
|
||||
"DomRect",
|
||||
"DomStringMap",
|
||||
"DomTokenList",
|
||||
"Element",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"EventListener",
|
||||
"FontFace",
|
||||
"FontFaceSet",
|
||||
"FontFaceSetIterator",
|
||||
"FontFaceSetIteratorResult",
|
||||
"HtmlCollection",
|
||||
"HtmlElement",
|
||||
"HtmlTextAreaElement",
|
||||
"HtmlStyleElement",
|
||||
"HtmlSelectElement",
|
||||
"InputEvent",
|
||||
"KeyboardEvent",
|
||||
"MutationObserver",
|
||||
"MutationObserverInit",
|
||||
"MutationRecord",
|
||||
"Navigator",
|
||||
"Node",
|
||||
"NodeList",
|
||||
"Performance",
|
||||
"PerformanceMark",
|
||||
"Range",
|
||||
"Selection",
|
||||
"ShadowRoot",
|
||||
"ShadowRootMode",
|
||||
"ShadowRootInit",
|
||||
"StyleSheetList",
|
||||
"Url",
|
||||
"Window",
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
# `perspective-viewer`
|
||||
|
||||
This crate provides the `<perspective-viewer>` Web Component, via JavaScript
|
||||
bindings generated from `wasm_bindgen`.
|
||||
@@ -0,0 +1,192 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { build } from "@perspective-dev/esbuild-plugin/build.js";
|
||||
import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js";
|
||||
import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
bundle as bundleCss,
|
||||
composeVisitors,
|
||||
bundleAsync as bundleAsyncCss,
|
||||
} from "lightningcss";
|
||||
import { compress } from "pro_self_extracting_wasm";
|
||||
import { get_host, inlineUrlVisitor, resolveNPM } from "./tools.mjs";
|
||||
|
||||
const IS_DEBUG =
|
||||
!!process.env.PSP_DEBUG || process.argv.indexOf("--debug") >= 0;
|
||||
|
||||
const INHERIT = {
|
||||
stdio: "inherit",
|
||||
stderr: "inherit",
|
||||
};
|
||||
|
||||
export async function build_all() {
|
||||
if (!process.env.PSP_SKIP_WASM) {
|
||||
execSync(
|
||||
`cargo bundle --target=${get_host()} -- perspective_viewer ${IS_DEBUG ? "" : "--release"}`,
|
||||
INHERIT,
|
||||
);
|
||||
|
||||
await compress(
|
||||
"dist/wasm/perspective-viewer.wasm",
|
||||
"dist/wasm/perspective-viewer.wasm",
|
||||
);
|
||||
}
|
||||
|
||||
// JavaScript
|
||||
const BUILD = [
|
||||
// WASM assets inlined into a single monolithic `.js` file. No special
|
||||
// loades required, this version of Perspective should be the easiest
|
||||
// to use but also the least performant at load time.
|
||||
// {
|
||||
// 'Import via `<script type="module">`': true,
|
||||
// "Requires WASM bootstrap": false,
|
||||
// "Load as binary": false,
|
||||
// "Bundler friendly": true,
|
||||
// },
|
||||
{
|
||||
entryPoints: ["src/ts/perspective-viewer.inline.ts"],
|
||||
format: "esm",
|
||||
loader: { ".wasm": "binary" },
|
||||
outfile: "dist/esm/perspective-viewer.inline.js",
|
||||
plugins: [
|
||||
WorkerPlugin({
|
||||
inline: !process.env.PSP_DEBUG,
|
||||
// plugins: [GlslMinify(), LightningCssMinify()],
|
||||
// loader: {
|
||||
// ".css": "text",
|
||||
// ".glsl": "text",
|
||||
// },
|
||||
}),
|
||||
],
|
||||
},
|
||||
// No WASM assets inlined or linked.
|
||||
// {
|
||||
// 'Import via `<script type="module">`': true, // *****
|
||||
// "Requires WASM bootstrap": true,
|
||||
// "Load as binary": true,
|
||||
// "Bundler friendly": true,
|
||||
// },
|
||||
{
|
||||
entryPoints: ["src/ts/perspective-viewer.ts"],
|
||||
format: "esm",
|
||||
external: ["*.wasm"],
|
||||
outdir: "dist/esm",
|
||||
plugins: [
|
||||
WorkerPlugin({
|
||||
inline: true,
|
||||
// plugins: [GlslMinify(), LightningCssMinify()],
|
||||
// loader: {
|
||||
// ".css": "text",
|
||||
// ".glsl": "text",
|
||||
// },
|
||||
}),
|
||||
],
|
||||
},
|
||||
// WASM assets linked to relative path via `fetch()`. This efficiently
|
||||
// loading build is great for `<script>` tags but will give many
|
||||
// bundlers trouble.
|
||||
// {
|
||||
// 'Import via `<script type="module">`': true,
|
||||
// "Requires WASM bootstrap": false,
|
||||
// "Load as binary": true,
|
||||
// "Bundler friendly": false,
|
||||
// },
|
||||
{
|
||||
entryPoints: ["src/ts/perspective-viewer.cdn.ts"],
|
||||
format: "esm",
|
||||
loader: { ".wasm": "file" },
|
||||
outfile: "dist/cdn/perspective-viewer.js",
|
||||
plugins: [
|
||||
WorkerPlugin({
|
||||
inline: true,
|
||||
// plugins: [GlslMinify(), LightningCssMinify()],
|
||||
// loader: {
|
||||
// ".css": "text",
|
||||
// ".glsl": "text",
|
||||
// },
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await Promise.all(BUILD.map(build)).catch(() => process.exit(1));
|
||||
|
||||
// This is dumb. `splitting` param for `esbuild` outputs a `__require`/
|
||||
// `__exports`/`__esModule` polyfill and does not tree-shake it; this <1kb
|
||||
// file blocks downloading of the wasm asset until after alot of JavaScript has
|
||||
// parsed due to this multi-step download+eval. Luckily `esbuild` is quite fast
|
||||
// enough to just run another build to inline this one file `chunk.js`.
|
||||
const POSTBUILD = [
|
||||
{
|
||||
entryPoints: ["dist/cdn/perspective-viewer.js"],
|
||||
format: "esm",
|
||||
plugins: [NodeModulesExternal()],
|
||||
external: ["*.wasm", "*.worker.js", "*.main.js"],
|
||||
outdir: "dist/cdn",
|
||||
allowOverwrite: true,
|
||||
},
|
||||
];
|
||||
|
||||
await Promise.all(POSTBUILD.map(build)).catch(() => process.exit(1));
|
||||
|
||||
// Typecheck
|
||||
execSync("tsc --project tsconfig.json", INHERIT);
|
||||
|
||||
// Generate themes via lightningcss bundling.
|
||||
fs.mkdirSync("./dist/css/intl", { recursive: true });
|
||||
const themes = [
|
||||
"icons",
|
||||
"intl",
|
||||
"pro",
|
||||
"pro-dark",
|
||||
"botanical",
|
||||
"monokai",
|
||||
"phosphor",
|
||||
"solarized",
|
||||
"solarized-dark",
|
||||
"vaporwave",
|
||||
"gruvbox",
|
||||
"gruvbox-dark",
|
||||
"dracula",
|
||||
"themes",
|
||||
];
|
||||
|
||||
for (const name of themes) {
|
||||
const filename = `./src/themes/${name}.css`;
|
||||
const { code } = await bundleAsyncCss({
|
||||
filename,
|
||||
minify: true,
|
||||
visitor: inlineUrlVisitor(filename),
|
||||
resolver: resolveNPM(import.meta.url),
|
||||
});
|
||||
|
||||
fs.writeFileSync(`dist/css/${name}.css`, code);
|
||||
}
|
||||
|
||||
const intl_langs = ["de", "es", "fr", "ja", "pt", "zh"];
|
||||
for (const lang of intl_langs) {
|
||||
const filename = `./src/themes/intl/${lang}.css`;
|
||||
const { code } = await bundleAsyncCss({
|
||||
filename,
|
||||
minify: true,
|
||||
visitor: inlineUrlVisitor(filename),
|
||||
});
|
||||
|
||||
fs.writeFileSync(`dist/css/intl/${lang}.css`, code);
|
||||
}
|
||||
}
|
||||
|
||||
build_all();
|
||||
@@ -0,0 +1,41 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use glob::glob;
|
||||
use lightningcss::bundler::{Bundler, FileProvider};
|
||||
use lightningcss::stylesheet::PrinterOptions;
|
||||
|
||||
fn main() -> Result<(), anyhow::Error> {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let out_path = std::path::Path::new(&out_dir);
|
||||
let fs: &'static FileProvider = Box::leak(Box::new(FileProvider::new()));
|
||||
for entry in glob("./src/css/**/*.css")? {
|
||||
let entry = entry?;
|
||||
let relative = entry
|
||||
.strip_prefix("./src/css/")
|
||||
.or_else(|_| entry.strip_prefix("src/css/"))?;
|
||||
|
||||
let mut bundler = Bundler::new(fs, None, Default::default());
|
||||
let stylesheet = bundler.bundle(&entry)?;
|
||||
let output = stylesheet.to_css(PrinterOptions {
|
||||
minify: true,
|
||||
..Default::default()
|
||||
})?;
|
||||
|
||||
let out_file = out_path.join("css").join(relative);
|
||||
std::fs::create_dir_all(out_file.parent().unwrap())?;
|
||||
std::fs::write(&out_file, output.code)?;
|
||||
println!("cargo:rerun-if-changed={}", entry.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import * as fs from "node:fs";
|
||||
|
||||
fs.rmSync("dist", { recursive: true, force: true });
|
||||
fs.rmSync("build", { recursive: true, force: true });
|
||||
@@ -0,0 +1,15 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import "zx/globals";
|
||||
|
||||
await $`typedoc --tsconfig tsconfig.json --out ../../docs/static/viewer`;
|
||||
@@ -0,0 +1,26 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/monokai.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/languages/javascript.min.js"></script>
|
||||
<script type="module">
|
||||
document.querySelectorAll("pre:not(.rust) code:not(.rust)").forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
code.language-python,
|
||||
code.language-bash,
|
||||
code.language-xml,
|
||||
code.language-javascript,
|
||||
pre {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
pre code.hljs {
|
||||
padding: 0;
|
||||
}
|
||||
pre.rust,
|
||||
pre.language-python,
|
||||
div.rust,
|
||||
div.python {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
The JavaScript language bindings for`<perspective-viewer>` Custom Element, the
|
||||
main UI for [Perspective](https://perspective-dev.github.io).
|
||||
|
||||
<div class="warning">
|
||||
The examples in this module are in JavaScript. See <a href="https://docs.rs/crate/perspective/latest"><code>perspective</code></a> docs for the Rust API.
|
||||
</div>
|
||||
|
||||
## `<perspective-viewer>` Custom Element library
|
||||
|
||||
`<perspective-viewer>` provides a complete graphical UI for configuring the
|
||||
`perspective` library and formatting its output to the provided visualization
|
||||
plugins.
|
||||
|
||||
If you are using `esbuild` or another bundler which supports ES6 modules, you
|
||||
only need to import the `perspective-viewer` libraries somewhere in your
|
||||
application - these modules export nothing, but rather register the components
|
||||
for use within your site's regular HTML:
|
||||
|
||||
```javascript
|
||||
import "@perspective-dev/viewer";
|
||||
import "@perspective-dev/viewer-datagrid";
|
||||
import "@perspective-dev/viewer-charts";
|
||||
```
|
||||
|
||||
Once imported, the `<perspective-viewer>` Web Component will be available in any
|
||||
standard HTML on your site. A simple example:
|
||||
|
||||
```html
|
||||
<perspective-viewer id="view1"></perspective-viewer>
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```javascript
|
||||
const viewer = document.createElement("perspective-viewer");
|
||||
```
|
||||
|
||||
### Theming
|
||||
|
||||
Theming is supported in `perspective-viewer` and its accompanying plugins. A
|
||||
number of themes come bundled with `perspective-viewer`; you can import any of
|
||||
these themes directly into your app, and the `perspective-viewer`s will be
|
||||
themed accordingly:
|
||||
|
||||
```javascript
|
||||
// Themes based on Thought Merchants's Prospective design
|
||||
import "@perspective-dev/viewer/dist/css/pro.css";
|
||||
import "@perspective-dev/viewer/dist/css/pro-dark.css";
|
||||
|
||||
// Other themes
|
||||
import "@perspective-dev/viewer/dist/css/solarized.css";
|
||||
import "@perspective-dev/viewer/dist/css/solarized-dark.css";
|
||||
import "@perspective-dev/viewer/dist/css/monokai.css";
|
||||
import "@perspective-dev/viewer/dist/css/vaporwave.css";
|
||||
```
|
||||
|
||||
Alternatively, you may use `themes.css`, which bundles all default themes
|
||||
|
||||
```javascript
|
||||
import "@perspective-dev/viewer/dist/css/themes.css";
|
||||
```
|
||||
|
||||
If you choose not to bundle the themes yourself, they are available through
|
||||
[CDN](https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/). These can
|
||||
be directly linked in your HTML file:
|
||||
|
||||
```html
|
||||
<link
|
||||
rel="stylesheet"
|
||||
crossorigin="anonymous"
|
||||
href="https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/pro.css"
|
||||
/>
|
||||
```
|
||||
|
||||
Note the `crossorigin="anonymous"` attribute. When including a theme from a
|
||||
cross-origin context, this attribute may be required to allow
|
||||
`<perspective-viewer>` to detect the theme. If this fails, additional themes are
|
||||
added to the `document` after `<perspective-viewer>` init, or for any other
|
||||
reason theme auto-detection fails, you may manually inform
|
||||
`<perspective-viewer>` of the available theme names with the `.resetThemes()`
|
||||
method.
|
||||
|
||||
```javascript
|
||||
// re-auto-detect themes
|
||||
viewer.resetThemes();
|
||||
|
||||
// Set available themes explicitly (they still must be imported as CSS!)
|
||||
viewer.resetThemes(["Pro Light", "Pro Dark"]);
|
||||
```
|
||||
|
||||
`<perspective-viewer>` will default to the first loaded theme when initialized.
|
||||
You may override this via `.restore()`, or provide an initial theme by setting
|
||||
the `theme` attribute:
|
||||
|
||||
```html
|
||||
<perspective-viewer theme="Pro Light"></perspective-viewer>
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```javascript
|
||||
const viewer = document.querySelector("perspective-viewer");
|
||||
await viewer.restore({ theme: "Pro Dark" });
|
||||
```
|
||||
|
||||
### Loading data into `<perspective-viewer>`
|
||||
|
||||
Data can be loaded into `<perspective-viewer>` in the form of a `Table()` or a
|
||||
`Promise<Table>` via the `load()` method.
|
||||
|
||||
```javascript
|
||||
// Create a new worker, then a new table promise on that worker.
|
||||
const worker = await perspective.worker();
|
||||
const table = await worker.table(data);
|
||||
|
||||
// Bind a viewer element to this table.
|
||||
await viewer.load(table);
|
||||
```
|
||||
|
||||
### Sharing a `table()` between multiple `perspective-viewer`s
|
||||
|
||||
Multiple `perspective-viewer`s can share a `table()` by passing the `table()`
|
||||
into the `load()` method of each viewer. Each `perspective-viewer` will update
|
||||
when the underlying `table()` is updated, but `table.delete()` will fail until
|
||||
all `perspective-viewer` instances referencing it are also deleted:
|
||||
|
||||
```javascript
|
||||
const viewer1 = document.getElementById("viewer1");
|
||||
const viewer2 = document.getElementById("viewer2");
|
||||
|
||||
// Create a new WebWorker
|
||||
const worker = await perspective.worker();
|
||||
|
||||
// Create a table in this worker
|
||||
const table = await worker.table(data);
|
||||
|
||||
// Load the same table in 2 different <perspective-viewer> elements
|
||||
await viewer1.load(table);
|
||||
await viewer2.load(table);
|
||||
|
||||
// Both `viewer1` and `viewer2` will reflect this update
|
||||
await table.update([{ x: 5, y: "e", z: true }]);
|
||||
```
|
||||
|
||||
### Server-only via `WebSocketServer()` and Node.js
|
||||
|
||||
Loading a virtual (server-only) [`Table`] works just like loading a local/Web
|
||||
Worker [`Table`] - just pass the virtual [`Table`] to `viewer.load()`:
|
||||
|
||||
In the browser:
|
||||
|
||||
```javascript
|
||||
const elem = document.getElementsByTagName("perspective-viewer")[0];
|
||||
|
||||
// Bind to the server's worker instead of instantiating a Web Worker.
|
||||
const websocket = await perspective.websocket(
|
||||
window.location.origin.replace("http", "ws"),
|
||||
);
|
||||
|
||||
// Bind the viewer to the preloaded data source. `table` and `view` objects
|
||||
// live on the server.
|
||||
const server_table = await websocket.open_table("table_one");
|
||||
await elem.load(server_table);
|
||||
|
||||
// Or load data from a table using a view. The browser now also has a copy of
|
||||
// this view in its own `table`, as well as its updates transferred to the
|
||||
// browser using Apache Arrow.
|
||||
const worker = await perspective.worker();
|
||||
const server_view = await server_table.view();
|
||||
const client_table = worker.table(server_view);
|
||||
await elem.load(client_table);
|
||||
```
|
||||
|
||||
`<perspective-viewer>` instances bound in this way are otherwise no different
|
||||
than `<perspective-viewer>`s which rely on a Web Worker, and can even share a
|
||||
host application with Web Worker-bound `table()`s. The same `promise`-based API
|
||||
is used to communicate with the server-instantiated `view()`, only in this case
|
||||
it is over a websocket.
|
||||
|
||||
### Persistent `<perspective-viewer>` configuration via `save()`/`restore()`.
|
||||
|
||||
`<perspective-viewer>` is _persistent_, in that its entire state (sans the data
|
||||
itself) can be serialized or deserialized. This include all column, filter,
|
||||
pivot, expressions, etc. properties, as well as datagrid style settings, config
|
||||
panel visibility, and more. This overloaded feature covers a range of use cases:
|
||||
|
||||
- Setting a `<perspective-viewer>`'s initial state after a `load()` call.
|
||||
- Updating a single or subset of properties, without modifying others.
|
||||
- Resetting some or all properties to their data-relative default.
|
||||
- Persisting a user's configuration to `localStorage` or a server.
|
||||
|
||||
#### Serializing and deserializing the viewer state
|
||||
|
||||
To retrieve the entire state as a JSON-ready JavaScript object, use the `save()`
|
||||
method. `save()` also supports a few other formats such as `"arraybuffer"` and
|
||||
`"string"` (base64, not JSON), which you may choose for size at the expense of
|
||||
easy migration/manual-editing.
|
||||
|
||||
```javascript
|
||||
const json_token = await elem.save();
|
||||
const string_token = await elem.save("string");
|
||||
```
|
||||
|
||||
For any format, the serialized token can be restored to any
|
||||
`<perspective-viewer>` with a `Table` of identical schema, via the `restore()`
|
||||
method. Note that while the data for a token returned from `save()` may differ,
|
||||
generally its schema may not, as many other settings depend on column names and
|
||||
types.
|
||||
|
||||
```javascript
|
||||
await elem.restore(json_token);
|
||||
await elem.restore(string_token);
|
||||
```
|
||||
|
||||
As `restore()` dispatches on the token's type, it is important to make sure that
|
||||
these types match! A common source of error occurs when passing a
|
||||
JSON-stringified token to `restore()`, which will assume base64-encoded msgpack
|
||||
when a string token is used.
|
||||
|
||||
```javascript
|
||||
// This will error!
|
||||
await elem.restore(JSON.stringify(json_token));
|
||||
```
|
||||
|
||||
#### Updating individual properties
|
||||
|
||||
Using the JSON format, every facet of a `<perspective-viewer>`'s configuration
|
||||
can be manipulated from JavaScript using the `restore()` method. The valid
|
||||
structure of properties is described via the
|
||||
[`ViewerConfig`](https://github.com/perspective-dev/perspective/blob/ebced4caa/rust/perspective-viewer/src/ts/viewer.ts#L16)
|
||||
and embedded
|
||||
[`ViewConfig`](https://github.com/perspective-dev/perspective/blob/ebced4caa19435a2a57d4687be7e428a4efc759b/packages/perspective/index.d.ts#L140)
|
||||
type declarations, and [`View`](view.md) chapter of the documentation which has
|
||||
several interactive examples for each `ViewConfig` property.
|
||||
|
||||
```javascript
|
||||
// Set the plugin (will also update `columns` to plugin-defaults)
|
||||
await elem.restore({ plugin: "X Bar" });
|
||||
|
||||
// Update plugin and columns (only draws once)
|
||||
await elem.restore({ plugin: "X Bar", columns: ["Sales"] });
|
||||
|
||||
// Open the config panel
|
||||
await elem.restore({ settings: true });
|
||||
|
||||
// Create an expression
|
||||
await elem.restore({
|
||||
columns: ['"Sales" + 100'],
|
||||
expressions: { "New Column": '"Sales" + 100' },
|
||||
});
|
||||
|
||||
// ERROR if the column does not exist in the schema or expressions
|
||||
// await elem.restore({columns: ["\"Sales\" + 100"], expressions: {}});
|
||||
|
||||
// Add a filter
|
||||
await elem.restore({ filter: [["Sales", "<", 100]] });
|
||||
|
||||
// Add a sort, don't remove filter
|
||||
await elem.restore({ sort: [["Prodit", "desc"]] });
|
||||
|
||||
// Reset just filter, preserve sort
|
||||
await elem.restore({ filter: undefined });
|
||||
|
||||
// Reset all properties to default e.g. after `load()`
|
||||
await elem.reset();
|
||||
```
|
||||
|
||||
Another effective way to quickly create a token for a desired configuration is
|
||||
to simply copy the token returned from `save()` after settings the view manually
|
||||
in the browser. The JSON format is human-readable and should be quite easy to
|
||||
tweak once generated, as `save()` will return even the default settings for all
|
||||
properties. You can call `save()` in your application code, or e.g. through the
|
||||
Chrome developer console:
|
||||
|
||||
```javascript
|
||||
// Copy to clipboard
|
||||
copy(await document.querySelector("perspective-viewer").save());
|
||||
```
|
||||
|
||||
### Update events
|
||||
|
||||
Whenever a `<perspective-viewer>`s underlying `table()` is changed via the
|
||||
`load()` or `update()` methods, a `perspective-view-update` DOM event is fired.
|
||||
Similarly, `view()` updates instigated either through the Attribute API or
|
||||
through user interaction will fire a `perspective-config-update` event:
|
||||
|
||||
```javascript
|
||||
elem.addEventListener("perspective-config-update", function (event) {
|
||||
var config = elem.save();
|
||||
console.log("The view() config has changed to " + JSON.stringify(config));
|
||||
});
|
||||
```
|
||||
|
||||
### Click events
|
||||
|
||||
Whenever a `<perspective-viewer>`'s grid or chart is clicked, a
|
||||
`perspective-click` DOM event is fired containing a detail object with `config`,
|
||||
`column_names`, and `row`.
|
||||
|
||||
The `config` object contains an array of `filters` that can be applied to a
|
||||
`<perspective-viewer>` through the use of `restore()` updating it to show the
|
||||
filtered subset of data.
|
||||
|
||||
The `column_names` property contains an array of matching columns, and the `row`
|
||||
property returns the associated row data.
|
||||
|
||||
```javascript
|
||||
elem.addEventListener("perspective-click", function (event) {
|
||||
var config = event.detail.config;
|
||||
elem.restore(config);
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@perspective-dev/viewer",
|
||||
"version": "4.5.2",
|
||||
"description": "The `<perspective-viewer>` Custom Element, frontend for Perspective",
|
||||
"keywords": [
|
||||
"perspective",
|
||||
"data",
|
||||
"analytics",
|
||||
"visualization",
|
||||
"datagrid",
|
||||
"charts",
|
||||
"web-component",
|
||||
"wasm",
|
||||
"arrow"
|
||||
],
|
||||
"homepage": "https://perspective.finos.org",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/perspective-dev/perspective"
|
||||
},
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"sideEffects": true,
|
||||
"unpkg": "dist/cdn/perspective-viewer.js",
|
||||
"jsdelivr": "dist/cdn/perspective-viewer.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/esm/perspective-viewer.d.ts",
|
||||
"import": "./dist/esm/perspective-viewer.js",
|
||||
"default": "./dist/esm/perspective-viewer.js"
|
||||
},
|
||||
"./inline": {
|
||||
"types": "./dist/esm/perspective-viewer.d.ts",
|
||||
"import": "./dist/esm/perspective-viewer.inline.js",
|
||||
"default": "./dist/esm/perspective-viewer.inline.js"
|
||||
},
|
||||
"./themes": "./dist/css/themes.css",
|
||||
"./themes/*.css": "./dist/css/*.css",
|
||||
"./themes/intl/*.css": "./dist/css/intl/*.css",
|
||||
"./dist/*": "./dist/*",
|
||||
"./src/*": "./src/*",
|
||||
"./test/*": "./test/*",
|
||||
"./package.json": "./package.json",
|
||||
"./tsconfig.json": "./tsconfig.json",
|
||||
"./tools.mjs": "./tools.mjs"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"src/**/*",
|
||||
"tsconfig.json"
|
||||
],
|
||||
"types": "dist/esm/perspective-viewer.d.ts",
|
||||
"scripts": {
|
||||
"build": "node ./build.mjs",
|
||||
"clean": "node ./clean.mjs",
|
||||
"docs": "node ./docs.mjs"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@perspective-dev/client": "workspace:",
|
||||
"pro_self_extracting_wasm": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@perspective-dev/esbuild-plugin": "workspace:",
|
||||
"@perspective-dev/test": "workspace:",
|
||||
"@perspective-dev/metadata": "workspace:",
|
||||
"lightningcss": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@playwright/experimental-ct-react": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typedoc": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"zx": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
@import "column-dropdown.css";
|
||||
@@ -0,0 +1,14 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
@import "containers/dropdown-menu.css";
|
||||
@@ -0,0 +1,14 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
@import "filter-dropdown.css";
|
||||
@@ -0,0 +1,14 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
@import "function-dropdown.css";
|
||||
@@ -0,0 +1,41 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
@import "dom/checkbox.css";
|
||||
@import "dom/scrollbar.css";
|
||||
@import "dom/select.css";
|
||||
|
||||
@import "containers/split-panel.css";
|
||||
@import "containers/scroll-panel.css";
|
||||
@import "containers/pairs-list.css";
|
||||
@import "containers/tabs.css";
|
||||
|
||||
@import "type-icon.css";
|
||||
@import "aggregate-selector.css";
|
||||
@import "empty-column.css";
|
||||
@import "filter-item.css";
|
||||
@import "config-selector.css";
|
||||
@import "column-selector.css";
|
||||
@import "column-style.css";
|
||||
@import "column-symbol-attributes.css";
|
||||
@import "column-settings-panel.css";
|
||||
@import "expression-editor.css";
|
||||
@import "plugin-selector.css";
|
||||
@import "plugin-settings-panel.css";
|
||||
@import "render-warning.css";
|
||||
@import "status-bar.css";
|
||||
|
||||
@import "form/code-editor.css";
|
||||
@import "form/debug.css";
|
||||
|
||||
@import "viewer.css";
|
||||
@@ -0,0 +1,75 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
/* Wrapper prevents the width of the selector options from increasing the width of */
|
||||
/* the container it is embedded in. */
|
||||
.aggregate-selector-wrapper {
|
||||
height: 19px;
|
||||
display: flex;
|
||||
padding-left: 6px;
|
||||
width: 68px;
|
||||
min-width: 68px;
|
||||
max-width: 68px;
|
||||
flex: 0 0 68px;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
height: 19px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 3px;
|
||||
max-width: 60px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aggregate-selector {
|
||||
border-bottom-width: 0px;
|
||||
margin-top: 0px;
|
||||
height: 19px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 12px;
|
||||
border: 1px solid transparent;
|
||||
select {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--psp--color);
|
||||
border-radius: 2px;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
label + .aggregate-selector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
label + .aggregate-selector {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-width-container:after {
|
||||
content: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
outline: none;
|
||||
font-size: 0.75em;
|
||||
border: inherit;
|
||||
/* box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%); */
|
||||
user-select: none;
|
||||
background-color: var(--psp--color, #fff) !important;
|
||||
color: var(--psp--background-color, #333) !important;
|
||||
/* padding: 6px 8px; */
|
||||
border: 1px solid var(--psp--color) !important;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
&:hover {
|
||||
--fix: ;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: inset 0px 0px 0 4px var(--psp-inactive--color);
|
||||
}
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
border: 1px solid transparent;
|
||||
box-shadow: inset 0px 0px 0 4px var(--psp-inactive--color);
|
||||
}
|
||||
&::-webkit-scrollbar,
|
||||
&::-webkit-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.selected {
|
||||
background-color: var(--psp--background-color) !important;
|
||||
color: var(--psp--color) !important;
|
||||
}
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
padding: 4px 5px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 3px 24px 6px 11px;
|
||||
color: var(--psp-error--color);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.no-results:before {
|
||||
content: var(--psp-label--no-results--content, "Invalid Column");
|
||||
}
|
||||
|
||||
#add-expression {
|
||||
.icon {
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
display: inline-flex;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
margin-right: 5px;
|
||||
-webkit-mask-image: var(--psp-icon--add-expression--mask-image);
|
||||
mask-image: var(--psp-icon--add-expression--mask-image);
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--add-expression--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
.selected {
|
||||
background-color: var(--psp--color, #fff) !important;
|
||||
color: var(--psp--background-color, #333) !important;
|
||||
}
|
||||
|
||||
span:hover,
|
||||
span.selected:hover {
|
||||
background-color: var(--psp--background-color) !important;
|
||||
color: var(--psp--color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host(.no-results) {
|
||||
overflow: hidden;
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#add-expression-button:before {
|
||||
content: var(--psp-label--add-expression-button--content, "New Column");
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8333333em;
|
||||
}
|
||||
|
||||
.column-selector-column-title {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.column_name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 0 1 auto;
|
||||
padding-left: 6px;
|
||||
display: inline-block;
|
||||
align-items: center;
|
||||
line-height: normal;
|
||||
|
||||
&:only-child {
|
||||
padding-right: 12px;
|
||||
}
|
||||
&.none:only-child {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.column-selector--spacer {
|
||||
width: 67px;
|
||||
flex: 0 100000000 67px;
|
||||
}
|
||||
|
||||
/* Inactive columns need to account for expression buttons, which won't be */
|
||||
/* the last child if it has a button */
|
||||
#sub-columns .column-selector--spacer:last-child {
|
||||
width: 93px;
|
||||
flex: 0 100000000 93px;
|
||||
}
|
||||
|
||||
.show-aggregate .column-selector--spacer {
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
.show-aggregate {
|
||||
.column_name:only-child {
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
#sub-columns .column_name:only-child {
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
#add-expression {
|
||||
border: var(--column-add--border, 1px solid transparent);
|
||||
min-height: 24px;
|
||||
flex-direction: row;
|
||||
background-color: var(--psp-placeholder--background);
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 7px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
|
||||
/* Button icon */
|
||||
& > .icon {
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
display: inline-flex;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
margin-left: 9px;
|
||||
-webkit-mask-image: var(--psp-icon--add-expression--mask-image);
|
||||
mask-image: var(--psp-icon--add-expression--mask-image);
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--add-expression--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text label of the button e.g. "New Column" */
|
||||
& > span {
|
||||
align-self: center;
|
||||
margin-left: 7.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.modal-target,
|
||||
&.dragdrop-hover {
|
||||
color: var(--psp--background-color);
|
||||
background-color: var(--psp--color);
|
||||
& > .icon {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dragdrop-highlight {
|
||||
.pivot-column-border,
|
||||
.column-selector-column-border {
|
||||
border: 1px solid var(--psp--color);
|
||||
}
|
||||
}
|
||||
|
||||
.column-selector-column-border,
|
||||
.pivot-column-border {
|
||||
&.snap-drag-image {
|
||||
background-color: var(--psp--background-color) !important;
|
||||
border: 1px solid var(--psp--color) !important;
|
||||
position: absolute !important;
|
||||
pointer-events: none;
|
||||
left: 0;
|
||||
z-index: -100000;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
|
||||
.sort-icon,
|
||||
label,
|
||||
.dropdown-width-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.aggregate-selector-wrapper {
|
||||
min-width: 0px;
|
||||
max-width: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-selector-column-border {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.column-selector-border.snap-drag-image {
|
||||
& > *:not(.column_name) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.column_name {
|
||||
padding-left: 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dragdrop-highlight .column-selector-column .column-selector-border,
|
||||
.dragdrop-highlight
|
||||
.column-selector-column
|
||||
.column-selector-draggable.empty-named,
|
||||
.dragdrop-highlight
|
||||
#top_panel
|
||||
.pivot-column.column-empty
|
||||
.pivot-column-draggable {
|
||||
border-color: var(--psp--color);
|
||||
}
|
||||
|
||||
.column-selector-column {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
|
||||
/* Expression column toolbar buttons */
|
||||
span.expression-edit-button,
|
||||
span.expression-delete-button {
|
||||
padding-top: 1px;
|
||||
font-family: var(--psp-button--font-family, inherit);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px 0 4px;
|
||||
margin-right: 4px;
|
||||
margin-left: auto;
|
||||
border-radius: 3px;
|
||||
&:hover,
|
||||
&.is-editing {
|
||||
background-color: var(--psp--color);
|
||||
.icon {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.expression-edit-button.disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
span.expression-delete-button {
|
||||
padding-left: 5px;
|
||||
margin-right: 8px;
|
||||
margin-left: auto;
|
||||
|
||||
&:before {
|
||||
content: var(--column-close--content, "close");
|
||||
}
|
||||
}
|
||||
|
||||
span.expression-delete-button + span.expression-edit-button {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
span.expression-edit-button .icon {
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
display: inline-block;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(--psp-icon--column-settings--mask-image);
|
||||
mask-image: var(--psp-icon--column-settings--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--column-settings--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.column-selector-draggable {
|
||||
cursor: move;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-grow: 1;
|
||||
color: inherit;
|
||||
box-sizing: border-box;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
min-height: 28px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
&.show-aggregate {
|
||||
.column_name {
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&.empty-named {
|
||||
cursor: auto;
|
||||
padding-bottom: 0px;
|
||||
margin-bottom: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
&:not(.empty-named) .column-selector-column-border > .drag-handle {
|
||||
margin: 0px 0 0 6px;
|
||||
flex: 0 0 auto;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
display: inline-block;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(
|
||||
--psp-icon--psp-icon--column-drag-handle--mask-image
|
||||
);
|
||||
mask-image: var(
|
||||
--psp-icon--psp-icon--column-drag-handle--mask-image
|
||||
);
|
||||
|
||||
&:before {
|
||||
content: var(
|
||||
--psp-icon--psp-icon--column-drag-handle--mask-image
|
||||
);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is_column_active.inactive {
|
||||
min-width: 0px;
|
||||
margin: 0 6px 0 0;
|
||||
}
|
||||
|
||||
.is_column_active {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
font-size: var(--psp-column-selector--font-size, 14px);
|
||||
font-family: var(
|
||||
--column-selector--font-family,
|
||||
var(--psp-button--font-family, inherit)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
&.select-mode,
|
||||
&.toggle-mode {
|
||||
margin: 0 6.5px 4px 6.5px;
|
||||
}
|
||||
}
|
||||
|
||||
#selected-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#active-columns,
|
||||
#sub-columns {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: scroll;
|
||||
min-height: 20%;
|
||||
}
|
||||
|
||||
#sub-columns .scroll-panel-container:before {
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
padding: var(--column_type--padding, 0px 0px 0px 0px);
|
||||
position: absolute;
|
||||
height: 28px;
|
||||
top: -30px;
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
content: var(--psp-label--all-columns--content, "All Columns");
|
||||
}
|
||||
|
||||
#sub-columns .scroll-panel-container {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* #sub-acolumns .scroll-panel-container #add-expression {
|
||||
padding-top: 28px;
|
||||
margin-top: 0;
|
||||
} */
|
||||
|
||||
#sub-columns .column-selector-column.column-selector-column-hidden {
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
#sub-columns {
|
||||
position: relative;
|
||||
flex: 0 10000000 auto;
|
||||
}
|
||||
|
||||
#active-columns {
|
||||
display: flex;
|
||||
padding-bottom: 8px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
#active-columns,
|
||||
#column_settings_sidebar,
|
||||
#sub-columns {
|
||||
.is_column_active {
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
#active-columns,
|
||||
#column_settings_sidebar {
|
||||
width: 100%;
|
||||
|
||||
.is_column_active.select-mode {
|
||||
-webkit-mask-image: var(--psp-icon--radio-on--mask-image);
|
||||
mask-image: var(--psp-icon--radio-on--mask-image);
|
||||
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--radio-on--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:not(.required):hover {
|
||||
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
.is_column_active.toggle-mode {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-on--mask-image);
|
||||
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--checkbox-on--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:not(.required):hover {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
.is_column_active {
|
||||
&.required {
|
||||
opacity: 0.3;
|
||||
cursor: initial;
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.column-selector-column {
|
||||
position: relative;
|
||||
|
||||
.column-selector-draggable {
|
||||
width: calc(100% - 27px);
|
||||
}
|
||||
|
||||
.column-selector-column-border {
|
||||
border-color: var(--psp-inactive--color);
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
&:before {
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
left: 0px;
|
||||
padding: var(--column_type--padding, 0px 0px 0px 0px);
|
||||
position: absolute;
|
||||
margin-top: -43px;
|
||||
content: var(--default-column-title, attr(data-label));
|
||||
}
|
||||
|
||||
&[data-label] {
|
||||
margin-top: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dragover,
|
||||
.column-selector-column .column-selector-draggable.empty-named {
|
||||
background-color: var(--psp-placeholder--background);
|
||||
border-color: transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.dragover-container .is_column_active {
|
||||
opacity: 0;
|
||||
margin: 0 6px 0 0;
|
||||
min-width: 0px;
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
.column-selector-column .column-selector-draggable.dragover {
|
||||
border: 1px solid var(--psp--color);
|
||||
position: relative;
|
||||
&:before {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--psp--color);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
& > * {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-selector-column {
|
||||
.column-selector-column-border {
|
||||
background-color: var(--psp-placeholder--background);
|
||||
border: 1px solid transparent;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
#active-columns .column-selector-column.dragdrop-hover,
|
||||
#active-columns.dragdrop-highlight .column-selector-column,
|
||||
.column-selector-column.dragdrop-hover {
|
||||
.column-selector-column-border {
|
||||
border-color: var(--psp--color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Inactive Columns are the columns in the column selector not currently */
|
||||
/* selected for the `columns` field of the `ViewConfig`. */
|
||||
#sub-columns {
|
||||
padding-bottom: 8px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.is_column_active.select-mode {
|
||||
-webkit-mask-image: var(--psp-icon--radio-off--mask-image);
|
||||
mask-image: var(--psp-icon--radio-off--mask-image);
|
||||
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--radio-off--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
.is_column_active.toggle-mode {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-off--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-off--mask-image);
|
||||
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--checkbox-off--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
.column-selector-column {
|
||||
.column-selector-draggable {
|
||||
width: calc(100% - 27px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#column_settings_sidebar {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 4;
|
||||
|
||||
#attributes-button-panel {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#attributes-tab {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#attributes-tab > .split-panel-child {
|
||||
min-height: 200px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
/* NOTE: These should probably make their way to global form styling eventually. */
|
||||
.errored {
|
||||
outline-color: var(--psp-error--color);
|
||||
}
|
||||
|
||||
.item_title {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
}
|
||||
|
||||
input {
|
||||
&[type="text"],
|
||||
&[type="search"] {
|
||||
outline: 1px solid;
|
||||
outline-color: var(--psp-inactive--color);
|
||||
background-color: var(--psp--background-color);
|
||||
border: none;
|
||||
margin-bottom: 0.5em;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
&:disabled {
|
||||
background-color: var(--psp-inactive--color);
|
||||
}
|
||||
}
|
||||
|
||||
&[type="search"] {
|
||||
min-height: 24px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar_header_contents {
|
||||
display: flex;
|
||||
margin: 8px;
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
outline-width: 1px;
|
||||
outline-color: var(--psp-inactive--color);
|
||||
padding-left: 4px;
|
||||
|
||||
&.editable {
|
||||
outline-style: dashed;
|
||||
&:hover {
|
||||
outline-style: solid;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
&.editable:focus {
|
||||
outline-style: solid;
|
||||
background: var(--psp--background-color);
|
||||
}
|
||||
/* &.edited {
|
||||
outline-style: dashed;
|
||||
} */
|
||||
&.invalid {
|
||||
outline-color: var(--psp-error--color);
|
||||
}
|
||||
|
||||
.sidebar_header_title {
|
||||
line-height: normal;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
padding-left: 5px;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: unset;
|
||||
|
||||
&:disabled {
|
||||
background: none;
|
||||
outline: none;
|
||||
color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label#color-label:before {
|
||||
content: var(--psp-label--color--content, "Color");
|
||||
}
|
||||
|
||||
label#format-label:before {
|
||||
content: var(--psp-label--format--content, "Format");
|
||||
}
|
||||
|
||||
label#timezone-label:before {
|
||||
content: var(--psp-label--timezone--content, "Timezone");
|
||||
}
|
||||
|
||||
label#date-style-label:before {
|
||||
content: var(--psp-label--date-style--content, "Date Style");
|
||||
}
|
||||
|
||||
label#time-style-label:before {
|
||||
content: var(--psp-label--time-style--content, "Time Style");
|
||||
}
|
||||
|
||||
label#number_fg_mode-label:before {
|
||||
content: var(--psp-label--number-fg-mode--content, "Foreground");
|
||||
}
|
||||
|
||||
label#pos_fg_color-label:before {
|
||||
content: var(--psp-label--number-fg-mode--content, "Foreground");
|
||||
}
|
||||
|
||||
label#number_bg_mode-label:before {
|
||||
content: var(--psp-label--number-fg-mode--content, "Background");
|
||||
}
|
||||
|
||||
label#pos_bg_color-label:before {
|
||||
content: var(--psp-label--pos-bg-color--content, "Background");
|
||||
}
|
||||
|
||||
label#datetime_color_mode-label:before {
|
||||
content: var(--psp-label--datetime-color-mode--content, "Color");
|
||||
}
|
||||
|
||||
label#string_color_mode-label:before {
|
||||
content: var(--psp-label--string-color-mode--content, "Color");
|
||||
}
|
||||
|
||||
label#chart-type-label:before,
|
||||
label#chart_type-label:before {
|
||||
content: var(--psp-label--chart-type--content, "Chart Type");
|
||||
}
|
||||
|
||||
label#stack-label:before {
|
||||
content: var(--psp-label--stack--content, "Stack");
|
||||
}
|
||||
|
||||
label#interpolate-label:before {
|
||||
content: var(--psp-label--interpolate--content, "Interpolate null");
|
||||
}
|
||||
|
||||
label#series-label:before {
|
||||
content: var(--psp-label--series--content, "Series");
|
||||
}
|
||||
|
||||
label#color-range-label:before {
|
||||
content: var(--psp-label--color-range--content, "Color Range");
|
||||
}
|
||||
|
||||
label#style-label:before {
|
||||
content: var(--psp-label--style--content, "Style");
|
||||
}
|
||||
|
||||
label#minimum-integer-digits-label:before {
|
||||
content: var(
|
||||
--psp-label--minimum-integer-digits--content,
|
||||
"Minimum Integer Digits"
|
||||
);
|
||||
}
|
||||
|
||||
label#rounding-increment-label:before {
|
||||
content: var(
|
||||
--psp-label--rounding-increment--content,
|
||||
"Rounding Increment"
|
||||
);
|
||||
}
|
||||
|
||||
label#notation-label:before {
|
||||
content: var(--psp-label--notation--content, "Notation");
|
||||
}
|
||||
|
||||
label#use-grouping-label:before {
|
||||
content: var(--psp-label--use-grouping--content, "Use Grouping");
|
||||
}
|
||||
|
||||
label#sign-display-label:before {
|
||||
content: var(--psp-label--sign-display--content, "Sign Display");
|
||||
}
|
||||
|
||||
label#aggregate-depth-label:before {
|
||||
content: var(--psp-label--aggregate-depth--content, "Aggregate Depth");
|
||||
}
|
||||
|
||||
label#fg_gradient-label:before {
|
||||
content: var(--psp-label--fg-gradient--content, "Max Value");
|
||||
}
|
||||
|
||||
label#bg_gradient-label:before {
|
||||
content: var(--psp-label--bg-gradient--content, "Max Value");
|
||||
}
|
||||
|
||||
label#rounding-priority-label:before {
|
||||
content: var(
|
||||
--psp-label--rounding-priority--content,
|
||||
"Rounding Priority"
|
||||
);
|
||||
}
|
||||
|
||||
label#rounding-mode-label:before {
|
||||
content: var(--psp-label--rounding-mode--content, "Rounding Mode");
|
||||
}
|
||||
|
||||
label#trailing-zero-display-label:before {
|
||||
content: var(
|
||||
--psp-label--trailing-zero-display--content,
|
||||
"Trailing Zero Display"
|
||||
);
|
||||
}
|
||||
|
||||
label#significant-digits-label:before {
|
||||
content: var(
|
||||
--psp-label--significant-digits--content,
|
||||
"Significant Digits"
|
||||
);
|
||||
}
|
||||
|
||||
label#fractional-digits-label:before {
|
||||
content: var(
|
||||
--psp-label--fractional-digits--content,
|
||||
"Fractional Digits"
|
||||
);
|
||||
}
|
||||
|
||||
label#year-label:before {
|
||||
content: var(--psp-label--year--content, "Year");
|
||||
}
|
||||
|
||||
label#month-label:before {
|
||||
content: var(--psp-label--month--content, "Month");
|
||||
}
|
||||
|
||||
label#day-label:before {
|
||||
content: var(--psp-label--day--content, "Day");
|
||||
}
|
||||
|
||||
label#weekday-label:before {
|
||||
content: var(--psp-label--weekday--content, "Weekday");
|
||||
}
|
||||
|
||||
label#hour-label:before {
|
||||
content: var(--psp-label--hour--content, "Hour");
|
||||
}
|
||||
|
||||
label#minute-label:before {
|
||||
content: var(--psp-label--minute--content, "Minute");
|
||||
}
|
||||
|
||||
label#second-label:before {
|
||||
content: var(--psp-label--second--content, "Second");
|
||||
}
|
||||
|
||||
label#fractional-seconds-label:before {
|
||||
content: var(
|
||||
--psp-label--fractional-seconds--content,
|
||||
"Fractional Seconds"
|
||||
);
|
||||
}
|
||||
|
||||
label#hours-label:before {
|
||||
content: var(--psp-label--hours--content, "12/24 Hours");
|
||||
}
|
||||
|
||||
div.tab-title#Style:before {
|
||||
content: var(--psp-label--style-tab--content, "Style");
|
||||
}
|
||||
|
||||
div.tab-title#Attributes:before {
|
||||
content: var(--psp-label--attributes-tab--content, "Attributes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#column-style-container,
|
||||
#plugin-config-container {
|
||||
outline: none;
|
||||
user-select: none;
|
||||
|
||||
.bool-field-desc {
|
||||
font-size: 10px;
|
||||
flex: 1 1 auto;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 4px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.is-default-value .bool-field-container {
|
||||
background-color: var(--psp--background-color);
|
||||
border: 1px solid var(--psp-inactive--color);
|
||||
}
|
||||
|
||||
.bool-field-container {
|
||||
display: flex;
|
||||
border: 1px dashed var(--psp-inactive--color);
|
||||
border-radius: 3px;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&.no-style {
|
||||
font-style: italic;
|
||||
background-color: var(--psp-placeholder--background);
|
||||
}
|
||||
|
||||
.is-default-value .dropdown-width-container {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
.dropdown-width-container {
|
||||
cursor: pointer;
|
||||
height: 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
padding: 0 8px;
|
||||
border-radius: 2px;
|
||||
/* border: 1px solid transparent; */
|
||||
border-color: var(--psp-inactive--color, #666);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
height: 22px;
|
||||
/* padding-bottom: 2px; */
|
||||
/* border-bottom: 1px solid var(--input--border-color, #ccc); */
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
margin: 4px 0 2px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
span.reset-default-style-disabled {
|
||||
flex: 0 0 14px;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--psp-inactive--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
span.reset-default-style {
|
||||
flex: 0 0 22px;
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
/* margin-right: -4px; */
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(--psp-icon--close--mask-image);
|
||||
mask-image: var(--psp-icon--close--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--close--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.is-default-value input.parameter {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
input.parameter {
|
||||
background: none;
|
||||
color: inherit;
|
||||
border: 0px solid transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input.parameter[type="number"] {
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
width: 0;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
height: 24px;
|
||||
padding-left: 8px;
|
||||
border-width: 1px;
|
||||
border-color: var(
|
||||
--input--border-color,
|
||||
var(--psp-inactive--color, inherit)
|
||||
);
|
||||
}
|
||||
|
||||
input.parameter.parameter-min[type="number"] {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
input.parameter.parameter-max[type="number"] {
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-left-width: 0px;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.column-style-label {
|
||||
display: flex;
|
||||
padding: 4px 0px;
|
||||
}
|
||||
|
||||
.indent {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
appearance: none;
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(--psp-icon--radio-off--mask-image);
|
||||
mask-image: var(--psp-icon--radio-off--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--radio-off--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
-webkit-mask-image: var(--psp-icon--radio-on--mask-image);
|
||||
mask-image: var(--psp-icon--radio-on--mask-image);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
div.section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
div.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
fieldset.style-control {
|
||||
border: none;
|
||||
padding: 0px 0;
|
||||
}
|
||||
|
||||
.color-gradient-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex: 1 1 100%;
|
||||
.color-thermometer {
|
||||
flex: 1 1 auto;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-selector {
|
||||
display: grid;
|
||||
|
||||
input {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.color-label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
margin: 0;
|
||||
font-family: var(--psp-button--font-family, inherit);
|
||||
color: var(--sign--color, white);
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.pos-color-param {
|
||||
width: 36px;
|
||||
border-radius: 12px 0 0 12px;
|
||||
margin: 0 1px 0 0;
|
||||
}
|
||||
|
||||
&.neg-color-param {
|
||||
width: 36px;
|
||||
border-radius: 0 12px 12px 0;
|
||||
margin: 0 0 0 1px;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
::-moz-color-swatch,
|
||||
::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.operator {
|
||||
font-family: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
input[disabled]:before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
input.parameter[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
button#datetime_format {
|
||||
appearance: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--psp-inactive--color);
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
padding: 6px;
|
||||
font-size: 10px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:before {
|
||||
content: attr(data-title);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--psp--color);
|
||||
background-color: var(--psp--color);
|
||||
color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
&:hover:before {
|
||||
content: attr(data-title-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#attributes-symbols {
|
||||
position: relative;
|
||||
svg {
|
||||
width: 15px;
|
||||
height: auto;
|
||||
color: var(--psp--color);
|
||||
fill: var(--psp--color);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.toggle-mode.is_column_active {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row-selector {
|
||||
flex: 1;
|
||||
min-width: 0px;
|
||||
.column-selector-column-border {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
input.column-empty-input {
|
||||
padding: 0 4px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row-selector-errored input {
|
||||
border-color: var(--psp-error--color);
|
||||
}
|
||||
}
|
||||
|
||||
.pairs-list-item .dropdown-width-container {
|
||||
flex: 0 1 75px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
left: 0px;
|
||||
padding: var(--column_type--padding, 0px 0px 0px 0px);
|
||||
position: absolute;
|
||||
margin-top: -15px;
|
||||
content: attr(data-label);
|
||||
}
|
||||
|
||||
&.pairs-list[data-label] {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.column_name {
|
||||
padding: 0;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.symbol-selector-wrapper {
|
||||
width: 75px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#top_panel.dragdrop-highlight {
|
||||
.psp-text-field {
|
||||
border-bottom-color: var(--psp--color) !important;
|
||||
}
|
||||
|
||||
.pivot-column-border {
|
||||
border-color: var(--psp--color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
#top_panel.group-rollup-mode-total {
|
||||
#group_by {
|
||||
width: 100%;
|
||||
/* height: 26px; */
|
||||
.pivot-column {
|
||||
.pivot-column-total {
|
||||
min-height: 24px;
|
||||
margin-bottom: 4px;
|
||||
&:before {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
}
|
||||
|
||||
.column_name {
|
||||
color: var(--psp-inactive--color);
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
background-color: var(--psp-inactive--color);
|
||||
}
|
||||
|
||||
&:hover .pivot-column-border {
|
||||
border-color: var(--psp-inactive--color, #ababab);
|
||||
}
|
||||
|
||||
/* input { */
|
||||
/* background-color: var(--psp--background-color); */
|
||||
/* pointer-events: none; */
|
||||
/* border: 1px solid var(--psp-inactive--color); */
|
||||
/* color: var(--psp-inactive--color) !important; */
|
||||
/* // &:placeholder { */
|
||||
|
||||
/* // } */
|
||||
/* } */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#top_panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
padding: 0px 0px 12px 0px;
|
||||
max-width: 1000px;
|
||||
z-index: 1;
|
||||
|
||||
.row_close {
|
||||
margin: 0 6.5px 4px 6.5px;
|
||||
cursor: pointer;
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-on--mask-image);
|
||||
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--checkbox-on--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
.pivot-column {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
position: relative;
|
||||
|
||||
.pivot-column-border {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
background-color: var(--psp--background-color);
|
||||
border: 1px solid var(--psp-inactive--color, #ababab);
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding-right: 8px;
|
||||
|
||||
.column_name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-sizer {
|
||||
flex: 1 100000 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .pivot-column-border {
|
||||
border-color: var(--psp--color);
|
||||
}
|
||||
|
||||
.pivot-column-draggable {
|
||||
position: relative;
|
||||
width: calc(100% - 27px);
|
||||
min-height: 28px;
|
||||
padding-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&:not(.column-empty) .pivot-column-border > .drag-handle {
|
||||
flex: 0 0 auto;
|
||||
margin: 0px 0 0 6px;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
display: inline-block;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(
|
||||
--psp-icon--psp-icon--column-drag-handle--mask-image
|
||||
);
|
||||
mask-image: var(
|
||||
--psp-icon--psp-icon--column-drag-handle--mask-image
|
||||
);
|
||||
|
||||
&:before {
|
||||
content: var(
|
||||
--psp-icon--psp-icon--column-drag-handle--mask-image
|
||||
);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span:first-child {
|
||||
text-overflow: ellipsis;
|
||||
/* max-width: 250px; */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Column is being dragged-over, e.g. this is the drop indicator. */
|
||||
& .config-drop {
|
||||
background-color: var(--psp-placeholder--background);
|
||||
margin-right: 7px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid var(--psp--color);
|
||||
border-radius: 2px;
|
||||
overflow: visible;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
position: relative;
|
||||
&:before {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--psp--color);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
& > * {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label.pivot-selector-label {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#group_by label.pivot-selector-label:before {
|
||||
content: var(--psp-label--group-by--content, "Group By");
|
||||
}
|
||||
|
||||
#split_by label.pivot-selector-label:before {
|
||||
content: var(--psp-label--split-by--content, "Split By");
|
||||
}
|
||||
|
||||
#sort label.pivot-selector-label:before {
|
||||
content: var(--psp-label--sort--content, "Order By");
|
||||
}
|
||||
|
||||
#filter label.pivot-selector-label:before {
|
||||
content: var(--psp-label--filter--content, "Where");
|
||||
}
|
||||
|
||||
.rrow {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
padding: 24px 0px 12px 0px;
|
||||
|
||||
span[draggable] {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
|
||||
.rrow > div {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.rrow > * {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0px 0px -12px 0px;
|
||||
}
|
||||
|
||||
.psp-text-field ul {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.psp-text-field {
|
||||
position: relative;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 24px;
|
||||
background-size: 100% 200%;
|
||||
}
|
||||
|
||||
.psp-text-field > .psp-text-field__input {
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
border: 0px;
|
||||
line-height: 20px;
|
||||
vertical-align: bottom;
|
||||
padding-bottom: 0px;
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 0;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.psp-text-field__input + label {
|
||||
width: 0px;
|
||||
height: 20px;
|
||||
line-height: 17px;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
padding: 0px;
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.psp-text-field__input {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.psp-text-field__input + label {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
margin: var(--column-drop-label--margin, -16px 0px 0px 0px);
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pivot_controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: 15px;
|
||||
margin-bottom: -24px;
|
||||
margin-right: 28px;
|
||||
margin-top: 9px;
|
||||
select:hover {
|
||||
color: var(--psp--color, inherit);
|
||||
}
|
||||
}
|
||||
|
||||
.group_rollup_wrapper {
|
||||
margin-bottom: -30px;
|
||||
flex: 0 1 auto;
|
||||
color: var(--psp-inactive--color);
|
||||
padding-top: 15px;
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
select {
|
||||
font-size: var(--label--font-size);
|
||||
}
|
||||
}
|
||||
|
||||
#transpose_button {
|
||||
cursor: pointer;
|
||||
flex-grow: 0;
|
||||
font-family: inherit;
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
align-self: center;
|
||||
/* margin-bottom: -23px; */
|
||||
/* margin-top: 11.5px; */
|
||||
/* align-self: flex-end; */
|
||||
z-index: 1;
|
||||
min-height: 0px;
|
||||
/* margin-right: 30px; */
|
||||
|
||||
&:hover:before {
|
||||
color: var(--psp--color, inherit);
|
||||
}
|
||||
|
||||
&:before {
|
||||
font-family: var(--psp-button--font-family, inherit);
|
||||
color: var(--psp-inactive--color, #666);
|
||||
content: var(--psp-label--transpose-button--content, "Swap");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
/* margin-right: 8px; */
|
||||
cursor: pointer;
|
||||
font-family: var(--psp-button--font-family, inherit);
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--psp-active--color, inherit);
|
||||
}
|
||||
|
||||
&.desc {
|
||||
-webkit-mask-image: var(--psp-icon--sort-desc--mask-image);
|
||||
mask-image: var(--psp-icon--sort-desc--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-desc--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.asc {
|
||||
-webkit-mask-image: var(--psp-icon--sort-asc--mask-image);
|
||||
mask-image: var(--psp-icon--sort-asc--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-asc--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.none {
|
||||
-webkit-mask-image: var(--psp-icon--sort-none--mask-image);
|
||||
mask-image: var(--psp-icon--sort-none--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-none--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.asc.abs {
|
||||
-webkit-mask-image: var(--psp-icon--sort-abs-asc--mask-image);
|
||||
mask-image: var(--psp-icon--sort-abs-asc--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-abs-asc--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.desc.abs {
|
||||
-webkit-mask-image: var(--psp-icon--sort-abs-desc--mask-image);
|
||||
mask-image: var(--psp-icon--sort-abs-desc--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-abs-desc--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.col.asc {
|
||||
-webkit-mask-image: var(--psp-icon--sort-col-asc--mask-image);
|
||||
mask-image: var(--psp-icon--sort-col-asc--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-col-asc--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.col.desc {
|
||||
-webkit-mask-image: var(--psp-icon--sort-col-desc--mask-image);
|
||||
mask-image: var(--psp-icon--sort-col-desc--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-col-desc--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.col.asc.abs {
|
||||
-webkit-mask-image: var(--psp-icon--sort-abs-col-asc--mask-image);
|
||||
mask-image: var(--psp-icon--sort-abs-col-asc--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-abs-col-asc--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.col.desc.abs {
|
||||
-webkit-mask-image: var(--psp-icon--sort-abs-col-desc--mask-image);
|
||||
mask-image: var(--psp-icon--sort-abs-col-desc--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--sort-abs-col-desc--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
outline: none;
|
||||
font-size: 0.75em;
|
||||
border: inherit;
|
||||
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%);
|
||||
user-select: none;
|
||||
background-color: white;
|
||||
padding: 8px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 80px;
|
||||
|
||||
code {
|
||||
font-family: var(--psp-interface-monospace--font-family), monospace;
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--psp-inactive--color, #ccc);
|
||||
background: transparent;
|
||||
font-family: var(--psp-interface-monospace--font-family), monospace;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
color: var(--psp-error--color, #ff0000);
|
||||
border-color: var(--psp-error--color, #ff0000);
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dropdown-group-label {
|
||||
color: var(--psp-inactive--color);
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-group-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.dropdown-group-container span {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
font-style: italics;
|
||||
padding: 6px 24px;
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
.selected {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.dropdown-group-container span:hover {
|
||||
background-color: var(--psp--color);
|
||||
color: var(--psp--background-color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
.pairs-list {
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pairs-list-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: solid 1px var(--psp-inactive--border-color);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pairs-list-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:last-child .is_column_active {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
.scroll-panel-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scroll-panel-auto-width {
|
||||
height: 1px;
|
||||
/* margin-left: 6px; */
|
||||
/* margin-top: -1px; */
|
||||
}
|
||||
|
||||
.scroll-panel-content {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#modal_panel.split-panel.orient-reverse
|
||||
> .split-panel-child:not(:last-child):not(.is-width-override) {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.split-panel.orient-reverse
|
||||
> .split-panel-child:not(:last-child):not(.is-width-override) {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.split-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&.orient-vertical {
|
||||
flex-direction: column;
|
||||
|
||||
& > .split-panel-child {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
& > .split-panel-divider:hover {
|
||||
cursor: row-resize;
|
||||
}
|
||||
}
|
||||
|
||||
&.orient-reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
&.orient-vertical.orient-reverse {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
& > * {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
&:not(.orient-vertical) > *:first-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* The thing you click to drag the panel size. */
|
||||
.split-panel-divider {
|
||||
flex: 0 0 6px;
|
||||
transition: background-color 0.2s ease-out;
|
||||
z-index: var(--settings-panel-z-index);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
cursor: col-resize;
|
||||
}
|
||||
}
|
||||
|
||||
/* Make the elements embedded in each child stretch to fill the */
|
||||
/* container */
|
||||
.split-panel-child {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
& > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#format-tab {
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
#plugin-tab {
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
padding: 12px 0 0 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.tab-section:last-child {
|
||||
padding: 12px 0px 12px 8px;
|
||||
}
|
||||
|
||||
.tab-section {
|
||||
padding: 12px 0px 0px 8px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
input[type="checkbox"].alternate {
|
||||
appearance: none;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
padding: 0px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color, #ccc);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
font-family: var(--psp-button--font-family, inherit);
|
||||
-webkit-mask-image: var(
|
||||
--psp-label--inactive-column-selector--content,
|
||||
none
|
||||
);
|
||||
mask-image: var(--psp-label--inactive-column-selector--content, none);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-label--inactive-column-selector--content, none);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-radius: 2px;
|
||||
outline: 1px solid var(--psp--color);
|
||||
}
|
||||
|
||||
&:checked:hover {
|
||||
background-color: var(--psp--color, #ccc);
|
||||
}
|
||||
|
||||
&:checked {
|
||||
-webkit-mask-image: var(
|
||||
--psp-label--active-column-selector--content,
|
||||
none
|
||||
);
|
||||
mask-image: var(--psp-label--active-column-selector--content, none);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
float: left;
|
||||
appearance: none;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
padding: 0px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
margin: 0 5px 0 0;
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-off--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-off--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--checkbox-off--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-on--mask-image);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.2s;
|
||||
}
|
||||
|
||||
&:not(.alternate):hover {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
.scrollable {
|
||||
&:hover {
|
||||
--fix: ;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: inset 0px 0px 0 4px var(--psp-inactive--color);
|
||||
}
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
border: 1px solid transparent;
|
||||
box-shadow: inset 0px 0px 0 4px var(--psp-inactive--color);
|
||||
}
|
||||
&::-webkit-scrollbar,
|
||||
&::-webkit-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
.dropdown-width-container {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
|
||||
&:after {
|
||||
content: attr(data-value) " ";
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
padding-right: 17px;
|
||||
}
|
||||
|
||||
select {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
right: 4px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
border-radius: 0;
|
||||
border-width: 0px;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
color: inherit;
|
||||
padding: 0px 12px 0px 0px;
|
||||
/* font-size: 10px; */
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
background-image: var(--psp-icon--select-arrow--mask-image);
|
||||
background-position: right 2px center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
&.invert:focus-within,
|
||||
&.invert:hover {
|
||||
background-image: var(--psp-icon--select-arrow-hover--mask-image);
|
||||
}
|
||||
|
||||
option {
|
||||
color: var(--psp--color, inherit);
|
||||
background: var(--psp--background-color, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
--invalid-column-pattern: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' preserveAspectRatio='none' viewBox='0 0 100 24'><path d='M100 0 L0 24 ' stroke='black' stroke-width='1'/><path d='M0 0 L100 24 ' stroke='black' stroke-width='1'/></svg>");
|
||||
.column-empty {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#top_panel .column-invalid.pivot-column,
|
||||
.column-invalid.pivot-column {
|
||||
.column-invalid-input {
|
||||
mask-image: var(--invalid-column-pattern);
|
||||
-webkit-mask-image: var(--invalid-column-pattern);
|
||||
background-color: var(--psp--color);
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size:
|
||||
100% 100%,
|
||||
auto;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 7px);
|
||||
background-color: var(--psp-placeholder--background);
|
||||
border: 1px solid var(--psp--color);
|
||||
border-radius: 2px;
|
||||
margin-right: 6px;
|
||||
margin-bottom: 4px;
|
||||
min-height: 22px;
|
||||
width: calc(100% - 7px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.column-empty-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
cursor: auto;
|
||||
|
||||
background-color: var(--psp-placeholder--background);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
padding-bottom: 0px;
|
||||
margin-bottom: 4px;
|
||||
min-height: 24px;
|
||||
width: calc(100% - 7px);
|
||||
outline: none;
|
||||
padding-left: 10px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.column-empty-input:focus {
|
||||
color: var(--psp--background-color);
|
||||
background-color: var(--psp--color);
|
||||
border: 1px solid var(--psp--color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0px;
|
||||
border: 1px solid var(--psp-inactive--color);
|
||||
background-color: var(--psp--background-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.error {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 1.5em;
|
||||
color: var(--psp-error--color);
|
||||
top: 0.5em;
|
||||
left: 0.5em;
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
#textarea_editable,
|
||||
#line_numbers,
|
||||
#editor {
|
||||
background-color: var(--psp-inactive--border-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#attributes-expr {
|
||||
flex: 1 1 auto;
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#attribtues-tab .split-panel-child {
|
||||
max-height: 400px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#expression-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#editor-alias-container {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.item_title {
|
||||
margin-bottom: 3px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
input#expression-name {
|
||||
padding-left: 8px;
|
||||
color: inherit;
|
||||
|
||||
&:placeholder-shown {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.split-panel.orient-vertical > .split-panel-child #editor-container {
|
||||
height: calc(100% - 15px);
|
||||
}
|
||||
|
||||
#horizontal-resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
|
||||
&:hover {
|
||||
cursor: ew-resize;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
#psp-expression-editor-actions {
|
||||
display: flex;
|
||||
gap: 0.333333em;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#psp-expression-editor-meta {
|
||||
display: flex;
|
||||
flex: 0 0 24px;
|
||||
flex-direction: column;
|
||||
padding: 0.5em;
|
||||
gap: 0.25em;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#save-settings,
|
||||
#danger-zone {
|
||||
display: flex;
|
||||
gap: 0.333333em;
|
||||
}
|
||||
#save-settings-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.333333em;
|
||||
}
|
||||
|
||||
.psp-expression-editor__button {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
font-family: inherit;
|
||||
font-size: 0.8333em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--psp--color);
|
||||
border-radius: 2px;
|
||||
height: 24px;
|
||||
padding: 2px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
color: inherit;
|
||||
|
||||
&:not([disabled]):hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--psp--color);
|
||||
color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
outline: none;
|
||||
font-size: 0.75em;
|
||||
border: inherit;
|
||||
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%);
|
||||
user-select: none;
|
||||
background-color: white;
|
||||
padding: 6px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.selected {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
font-style: italics;
|
||||
padding: 6px 24px;
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
.selected {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
span:hover,
|
||||
span.selected:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
:-webkit-calendar-picker-indicator {
|
||||
filter: var(--psp-calendar--filter, none);
|
||||
}
|
||||
|
||||
#top_panel {
|
||||
.rrow {
|
||||
.input-sizer {
|
||||
display: inline-grid;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin-top: 1px;
|
||||
min-width: 45px;
|
||||
overflow: hidden;
|
||||
height: 16px;
|
||||
|
||||
&:after,
|
||||
input,
|
||||
textarea {
|
||||
width: auto;
|
||||
grid-area: 1 / 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
grid-area: 1 / 1;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--psp-inactive--color, #ccc);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: attr(data-value) " ";
|
||||
color: var(--psp-inactive--color, inherit);
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
&.num-filter {
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
&.num-filter:after {
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
input.date-filter {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
input.datetime-filter {
|
||||
width: 230px;
|
||||
}
|
||||
|
||||
input.time-filter {
|
||||
grid-area: 1/2;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
textarea:focus,
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The filter comparator */
|
||||
#filter {
|
||||
.pivot-column {
|
||||
input {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
text-overflow: ellipsis;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
&::-webkit-search-cancel-button {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
&:not(:empty)::-webkit-search-cancel-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-width-container {
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
|
||||
select {
|
||||
padding-right: 12px;
|
||||
padding-left: 4px;
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#editor {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
overflow-x: visible;
|
||||
overflow-y: auto;
|
||||
|
||||
#editor-height-sizer {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
#editor-inner {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
#line_numbers {
|
||||
color: var(--psp-inactive--color);
|
||||
background-color: var(--psp--background-color, white);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--psp-interface-monospace--font-family, monospace);
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
padding: 6px 0 6px 6px;
|
||||
pointer-events: none;
|
||||
span:after {
|
||||
color: var(--code-editor-error--color, red);
|
||||
white-space: pre;
|
||||
content: var(--status-icon--content, " X ");
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
span.error_highlight:after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
pre#content {
|
||||
margin: 0 6px 6px 0;
|
||||
padding: 6px 0;
|
||||
/* margin-left: 36px; */
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
font-weight: 400;
|
||||
font-family: var(--psp-interface-monospace--font-family, monospace);
|
||||
color: var(--code-editor-error--color, red);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
/* display: contents; */
|
||||
}
|
||||
|
||||
.comment {
|
||||
color: var(--psp-code-editor--comment--color, orange);
|
||||
}
|
||||
|
||||
.operator {
|
||||
color: var(--psp-code-editor--operator--color, green);
|
||||
}
|
||||
|
||||
.unknown {
|
||||
color: var(--code-editor-unknown--color, red);
|
||||
}
|
||||
|
||||
.symbol {
|
||||
color: var(--psp-code-editor--symbol--color, #242526);
|
||||
}
|
||||
|
||||
.column {
|
||||
color: var(--psp-code-editor--column--color, purple);
|
||||
}
|
||||
|
||||
.literal {
|
||||
color: var(--psp-code-editor--literal--color, blue);
|
||||
}
|
||||
|
||||
.error_highlight {
|
||||
/* background-color: */
|
||||
color: var(--code-editor-error--color, red);
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 0px solid var(--code-editor-error--color, red);
|
||||
border-bottom-width: 1px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
#textarea_editable {
|
||||
position: absolute;
|
||||
overscroll-behavior: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: var(--psp-interface-monospace--font-family, monospace);
|
||||
font-size: 1em;
|
||||
resize: none;
|
||||
padding: 6px 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: transparent;
|
||||
caret-color: var(--psp-code-editor--symbol--color, #242526);
|
||||
white-space: pre;
|
||||
box-sizing: border-box;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.wordwrap {
|
||||
#textarea_editable,
|
||||
#content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
div.tab-title#Debug:before {
|
||||
content: var(--psp-label--debug-tab--content, "Debug JSON");
|
||||
}
|
||||
|
||||
#debug-panel-overflow {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#debug-panel {
|
||||
display: flex;
|
||||
min-width: 150px;
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
}
|
||||
|
||||
#debug-panel-controls {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.333333em;
|
||||
padding: 12px 8px 0px 0px;
|
||||
align-items: center;
|
||||
button {
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--psp--color);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
flex: 0 1 100px;
|
||||
font-family: inherit;
|
||||
font-size: 0.8333em;
|
||||
text-transform: uppercase;
|
||||
height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 0px;
|
||||
&:not([disabled]):hover {
|
||||
background-color: var(--psp--color);
|
||||
color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#debug-panel-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
padding: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
#editor {
|
||||
background-color: var(--psp--background-color);
|
||||
border: 1px solid var(--psp-inactive--color);
|
||||
border-radius: 2px;
|
||||
font-size: 0.8em;
|
||||
/* height: 100%; */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
outline: none;
|
||||
font-size: 0.75em;
|
||||
border: inherit;
|
||||
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%);
|
||||
user-select: none;
|
||||
background-color: white;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
|
||||
.selected {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
.selected {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.selected:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
#settings_panel {
|
||||
#plugin_selector_container.open ~ * {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#plugin_selector_container {
|
||||
min-height: var(
|
||||
--psp-plugin-selector--height,
|
||||
var(--psp-status-bar--height, 48px)
|
||||
);
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
|
||||
/* Overflow the padding bounds of the container */
|
||||
/* TODO these bounds exist to protext the resizer and scrollbar (left & */
|
||||
/* right resp) but we really should find a way to remove it and delegate */
|
||||
/* to sub-containers. */
|
||||
margin: 0 0px 0 -9px;
|
||||
|
||||
#plugin_selector_border {
|
||||
height: 2px;
|
||||
min-height: 2px;
|
||||
width: 100%;
|
||||
background-color: var(--psp-inactive--color, #6e6e6e);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.plugin-selector-options {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.open {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
/* This element is first in the container but visually "overlaps" */
|
||||
/* when expanded */
|
||||
z-index: 3;
|
||||
color: var(--psp--color, #042121);
|
||||
/* background: var(--psp--background-color); */
|
||||
|
||||
.plugin-select-item {
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item-name {
|
||||
padding-left: 10px;
|
||||
font-size: 1.33333em;
|
||||
}
|
||||
|
||||
.plugin-select-item-name:before {
|
||||
/* This value is set in a `style` tag from Yew! */
|
||||
content: var(--default-column-title);
|
||||
}
|
||||
|
||||
.plugin-select-item {
|
||||
/* width: 48px; */
|
||||
padding-left: 9px;
|
||||
height: var(
|
||||
--psp-plugin-selector--height,
|
||||
var(--psp-status-bar--height, 48px)
|
||||
);
|
||||
min-height: var(
|
||||
--psp-plugin-selector--height,
|
||||
var(--psp-status-bar--height, 48px)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid transparent;
|
||||
box-shadow: 0px 1px var(--psp-inactive--color, #6e6e6e);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.plugin-select-item > .icon {
|
||||
display: inline-block;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
}
|
||||
|
||||
.plugin-select-item:hover {
|
||||
background-color: var(--psp--color, #042121);
|
||||
color: var(--psp--background-color, #fdfffd);
|
||||
}
|
||||
|
||||
.plugin-select-item:hover > .icon {
|
||||
background-color: var(--psp--background-color, #fdfffd);
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Candlestick"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--candlestick--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--candlestick--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--candlestick--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Heatmap"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--heatmap--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--heatmap--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--heatmap--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Density"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--density--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--density--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--density--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Map Scatter"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--map-scatter--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--map-scatter--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--map-scatter--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Map Line"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--map-line--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--map-line--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--map-line--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Map Density"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--map-density--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--map-density--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--map-density--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="OHLC"] > .icon {
|
||||
-webkit-mask-image: var(--psp-plugin-selector--ohlc--content);
|
||||
mask-image: var(--psp-plugin-selector--ohlc--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--ohlc--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Sunburst"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--sunburst--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--sunburst--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--sunburst--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Treemap"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--treemap--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--treemap--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--treemap--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="X Bar"] > .icon {
|
||||
-webkit-mask-image: var(--psp-plugin-selector--x-bar--content);
|
||||
mask-image: var(--psp-plugin-selector--x-bar--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--x-bar--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="X/Y Line"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--x-y-line--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--x-y-line--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--x-y-line--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="X/Y Scatter"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--x-y-scatter--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--x-y-scatter--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--x-y-scatter--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Y Area"] > .icon {
|
||||
-webkit-mask-image: var(--psp-plugin-selector--y-area--content);
|
||||
mask-image: var(--psp-plugin-selector--y-area--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--y-area--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Y Bar"] > .icon {
|
||||
-webkit-mask-image: var(--psp-plugin-selector--y-bar--content);
|
||||
mask-image: var(--psp-plugin-selector--y-bar--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--y-bar--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Y Line"] > .icon {
|
||||
-webkit-mask-image: var(--psp-plugin-selector--y-line--content);
|
||||
mask-image: var(--psp-plugin-selector--y-line--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--y-line--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Y Scatter"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--y-scatter--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--y-scatter--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--y-scatter--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-select-item[data-plugin="Datagrid"] > .icon {
|
||||
-webkit-mask-image: var(
|
||||
--psp-plugin-selector--datagrid--content
|
||||
);
|
||||
mask-image: var(--psp-plugin-selector--datagrid--content);
|
||||
&:before {
|
||||
content: var(--psp-plugin-selector--datagrid--content);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#plugin_selector {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
label#edit_mode-label:before {
|
||||
content: var(--psp-label--edit-mode--content);
|
||||
}
|
||||
|
||||
label#scroll_lock-label:before {
|
||||
content: var(--psp-label--scroll-lock--content);
|
||||
}
|
||||
|
||||
label#chart_type-label:before {
|
||||
content: var(--psp-label--chart-type--content);
|
||||
}
|
||||
|
||||
/* viewer-charts plugin_config controls */
|
||||
label#auto_alt_y_axis-label:before {
|
||||
content: var(--psp-label--auto-alt-y-axis--content);
|
||||
}
|
||||
|
||||
label#facet_mode-label:before {
|
||||
content: var(--psp-label--facet-mode--content);
|
||||
}
|
||||
|
||||
label#facet_zoom_mode-label:before {
|
||||
content: var(--psp-label--facet-zoom-mode--content);
|
||||
}
|
||||
|
||||
label#series_zoom_mode-label:before {
|
||||
content: var(--psp-label--series-zoom-mode--content);
|
||||
}
|
||||
|
||||
label#line_width_px-label:before {
|
||||
content: var(--psp-label--line-width-px--content);
|
||||
}
|
||||
|
||||
label#point_size_px-label:before {
|
||||
content: var(--psp-label--point-size-px--content);
|
||||
}
|
||||
|
||||
label#band_inner_frac-label:before {
|
||||
content: var(--psp-label--band-inner-frac--content);
|
||||
}
|
||||
|
||||
label#bar_inner_pad-label:before {
|
||||
content: var(--psp-label--bar-inner-pad--content);
|
||||
}
|
||||
|
||||
label#wick_width_px-label:before {
|
||||
content: var(--psp-label--wick-width-px--content);
|
||||
}
|
||||
|
||||
label#ohlc_line_width_px-label:before {
|
||||
content: var(--psp-label--ohlc-line-width-px--content);
|
||||
}
|
||||
|
||||
label#include_zero-label:before {
|
||||
content: var(--psp-label--include-zero--content);
|
||||
}
|
||||
|
||||
label#domain_mode-label:before {
|
||||
content: var(--psp-label--domain-mode--content);
|
||||
}
|
||||
|
||||
label#gradient_color_mode-label:before {
|
||||
content: var(--psp-label--gradient-color-mode--content);
|
||||
}
|
||||
|
||||
label#gradient_radius_px-label:before {
|
||||
content: var(--psp-label--gradient-radius-px--content);
|
||||
}
|
||||
|
||||
label#gradient_intensity-label:before {
|
||||
content: var(--psp-label--gradient-intensity--content);
|
||||
}
|
||||
|
||||
label#gradient_heat_max-label:before {
|
||||
content: var(--psp-label--gradient-heat-max--content);
|
||||
}
|
||||
|
||||
label#map_tile_provider-label:before {
|
||||
content: var(--psp-label--map-tile-provider--content);
|
||||
}
|
||||
|
||||
label#map_tile_alpha-label:before {
|
||||
content: var(--psp-label--map-tile-alpha--content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
/* @amber400: #ffca28; */
|
||||
|
||||
:host {
|
||||
.plugin_information {
|
||||
color: var(--psp-warning--color, inherit);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding: 0.5rem;
|
||||
z-index: 10000;
|
||||
user-select: none;
|
||||
left: 48px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
border-radius: 24px;
|
||||
align-items: center;
|
||||
|
||||
&.plugin_information--warning {
|
||||
background: var(--psp-warning--background, #ffca28);
|
||||
}
|
||||
|
||||
.plugin_information__text {
|
||||
margin-right: 0.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plugin_information__icon {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.plugin_information__icon:before {
|
||||
content: var(--psp-icon-overflow-hint--content);
|
||||
height: 20px;
|
||||
font-family: var(
|
||||
--overflow_hint--font-family,
|
||||
var(--psp-button--font-family, inherit)
|
||||
);
|
||||
color: var(--psp-icon-overflow-hint--color);
|
||||
padding: var(--overflow-hint-icon--padding, 0 12px 0 6px);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.plugin_information__action {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.plugin_information__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.plugin_information--overflow-hint-percent {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plugin_information--overflow-hint {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host(:hover) {
|
||||
#status_bar.settings-closed.floating {
|
||||
opacity: 1;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
/* When settings open ... */
|
||||
#main_column #status_bar,
|
||||
#main_column #status_bar.titled {
|
||||
input::placeholder {
|
||||
color: var(--psp-inactive--color);
|
||||
}
|
||||
}
|
||||
|
||||
#status_bar.settings-closed {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
#status_bar.updating {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#status_bar.settings-closed.floating {
|
||||
transition: opacity 0.2s;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
padding: 0px;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
gap: 6px;
|
||||
top: calc((var(--psp-status-bar--height, 48px) - 20px) / 2);
|
||||
right: calc(
|
||||
min(48px, (var(--psp-status-bar--height, 48px) - 20px) / 2)
|
||||
);
|
||||
}
|
||||
|
||||
#status_bar {
|
||||
box-shadow: 0 13px 0 -12px var(--psp-inactive--border-color);
|
||||
z-index: 3;
|
||||
display: var(--psp-status-bar--display, flex);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
background: var(--psp--background-color);
|
||||
padding: var(--psp-status-bar--padding, 0 31px 0 8px);
|
||||
position: relative;
|
||||
height: var(--psp-status-bar--height, 48px);
|
||||
min-height: var(--psp-status-bar--height, 48px);
|
||||
max-height: var(--psp-status-bar--height, 48px);
|
||||
border-radius: var(--psp-status-bar--border-radius);
|
||||
#status-bar-placeholder {
|
||||
margin: 0px;
|
||||
margin-right: -5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
input:placeholder-shown + #status-bar-placeholder:before {
|
||||
content: var(--psp-label--untitled--content, "untitled");
|
||||
color: var(--psp-inactive--color);
|
||||
}
|
||||
|
||||
.input-sizer {
|
||||
display: inline-block;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
min-width: 65px;
|
||||
overflow: hidden;
|
||||
padding-top: 0.5px;
|
||||
|
||||
&:after,
|
||||
input,
|
||||
textarea {
|
||||
width: auto;
|
||||
grid-area: 1 / 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: attr(data-value) " ";
|
||||
color: var(--psp-inactive--color, inherit);
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
textarea:focus,
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.section > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
#spacer {
|
||||
flex: 1 1000 auto;
|
||||
}
|
||||
|
||||
span#rows {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
flex: 0 1000 auto;
|
||||
overflow: hidden;
|
||||
margin: 0px 10px;
|
||||
pointer-events: none;
|
||||
span {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
span.x,
|
||||
span.total {
|
||||
color: var(--psp-inactive--color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Exceptions when the menu bar is visible and the settings panel is closed. */
|
||||
#menu-bar {
|
||||
flex: 0 1000000 auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#plugin-settings {
|
||||
margin-left: auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
margin: 0px 14px;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
span.icon {
|
||||
height: 100%;
|
||||
/* line-height: 36px; */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span#status {
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
pointer-events: none;
|
||||
image-rendering: pixelated;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
/* span#status_updating */
|
||||
|
||||
span#status.loading {
|
||||
image-rendering: pixelated;
|
||||
mask-image: var(--psp-icon--downloading--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--downloading--mask-image);
|
||||
&:before {
|
||||
visibility: hidden;
|
||||
content: var(--psp-icon--downloading--mask-image);
|
||||
line-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
span#status.uninitialized {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
span#status.connected,
|
||||
span#status.updating {
|
||||
mask-image: var(--psp-icon--status-ok--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--status-ok--mask-image);
|
||||
&:before {
|
||||
visibility: hidden;
|
||||
content: var(--psp-icon--status-ok--mask-image);
|
||||
line-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-dialog {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
max-height: 300px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0px 0px 0px 50px;
|
||||
display: none;
|
||||
white-space: pre-wrap;
|
||||
color: var(--psp--background-color);
|
||||
background: var(--psp--color);
|
||||
border-radius: var(--psp-status-bar--border-radius);
|
||||
}
|
||||
|
||||
.error-dialog-message {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding-right: 17px;
|
||||
height: var(--psp-status-bar--height, 48px);
|
||||
min-height: var(--psp-status-bar--height, 48px);
|
||||
}
|
||||
|
||||
.error-dialog-stack {
|
||||
font-size: 10px;
|
||||
padding-right: 17px;
|
||||
color: var(--psp-inactive--color);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.section:hover .error-dialog {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
div#status_reconnect {
|
||||
z-index: 2;
|
||||
display: var(--status-indicator--display, flex);
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
margin: 0;
|
||||
height: auto;
|
||||
border: 1px solid
|
||||
var(--psp-status-icon--ok--border-color, transparent);
|
||||
cursor: var(--psp-status-icon--ok--cursor);
|
||||
pointer-events: var(--psp-status-icon--pointer-events, none);
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--psp-status-icon--ok-hover--background-color,
|
||||
transparent
|
||||
);
|
||||
border-color: var(
|
||||
--psp-status-icon--ok-hover--border-color,
|
||||
transparent
|
||||
);
|
||||
span#status {
|
||||
background-color: var(
|
||||
--psp-status-icon--ok-hover--color,
|
||||
var(--psp--color)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.errored {
|
||||
pointer-events: all;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.errored.disabled {
|
||||
cursor: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
span#status.errored {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 10px;
|
||||
color: var(--psp--background-color);
|
||||
&:before {
|
||||
content: "!";
|
||||
}
|
||||
}
|
||||
|
||||
&.errored:hover {
|
||||
background-color: var(--psp--color);
|
||||
span#status.errored {
|
||||
color: var(--psp--color);
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Status bar status icon animations */
|
||||
span#status_updating {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
margin: 0 5px;
|
||||
opacity: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
pointer-events: none;
|
||||
image-rendering: pixelated;
|
||||
mask-image: var(--psp-icon--updating--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--updating--mask-image);
|
||||
&:before {
|
||||
visibility: hidden;
|
||||
content: var(--psp-icon--updating--mask-image);
|
||||
line-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
span#status.updating {
|
||||
animation-name: status-bar-updating-inverse;
|
||||
animation-fill-mode: forwards;
|
||||
animation-iteration-count: 1;
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
|
||||
span#status_updating.updating {
|
||||
animation-name: status-bar-updating;
|
||||
animation-fill-mode: forwards;
|
||||
animation-iteration-count: 1;
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
|
||||
.button > .icon {
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
pointer-events: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
span#export {
|
||||
& > .icon {
|
||||
-webkit-mask-image: var(--psp-icon--export--mask-image);
|
||||
mask-image: var(--psp-icon--export--mask-image);
|
||||
display: inline-flex;
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--export--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
span:not(.icon):before {
|
||||
content: var(--psp-label--export-button--content, "Export");
|
||||
}
|
||||
}
|
||||
|
||||
span#lock {
|
||||
& > .icon {
|
||||
-webkit-mask-image: var(--psp-icon--free-scroll--mask-image);
|
||||
mask-image: var(--psp-icon--free-scroll--mask-image);
|
||||
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--free-scroll--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span#reset {
|
||||
& > .icon {
|
||||
-webkit-mask-image: var(--psp-icon--reset--mask-image);
|
||||
mask-image: var(--psp-icon--reset--mask-image);
|
||||
display: inline-flex;
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--reset--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
span:not(.icon):before {
|
||||
content: var(--psp-label--reset-button--content);
|
||||
}
|
||||
}
|
||||
|
||||
span#copy {
|
||||
& > .icon {
|
||||
-webkit-mask-image: var(--psp-icon--copy--mask-image);
|
||||
mask-image: var(--psp-icon--copy--mask-image);
|
||||
display: inline-flex;
|
||||
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--copy--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
span:not(.icon):before {
|
||||
content: var(--psp-label--copy-button--content);
|
||||
}
|
||||
}
|
||||
|
||||
#theme_icon {
|
||||
margin-right: -32px;
|
||||
height: 100%;
|
||||
width: 28px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#theme_icon.modified {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#theme {
|
||||
align-self: stretch;
|
||||
border-radius: 0px;
|
||||
select {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& > .icon {
|
||||
margin: 0 6px 0 4px;
|
||||
height: auto;
|
||||
-webkit-mask-image: var(--psp-icon--theme--mask-image);
|
||||
mask-image: var(--psp-icon--theme--mask-image);
|
||||
display: inline-flex;
|
||||
&:before {
|
||||
line-height: 0;
|
||||
content: var(--psp-icon--theme--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#theme_icon.modified:hover + #theme > .icon {
|
||||
-webkit-mask-image: var(--psp-icon--close--mask-image);
|
||||
mask-image: var(--psp-icon--close--mask-image);
|
||||
}
|
||||
|
||||
#theme_selector {
|
||||
max-width: 100px;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0px;
|
||||
padding: 0 5px;
|
||||
color: var(--psp-inactive--color, #ccc);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
|
||||
& > span,
|
||||
& > .dropdown-width-container {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > .icon {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hover-target {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
|
||||
&:focus-within,
|
||||
&:hover,
|
||||
&.modal-target {
|
||||
box-shadow:
|
||||
-4px 0 0 var(--psp--color),
|
||||
4px 0 0 var(--psp--color);
|
||||
background-color: var(--psp--color);
|
||||
}
|
||||
|
||||
&:focus-within .button select,
|
||||
&:hover .button select {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.modal-target .button > span {
|
||||
color: var(--psp--color) !important;
|
||||
}
|
||||
|
||||
&:focus-within .button,
|
||||
&:hover .button,
|
||||
&.modal-target .button {
|
||||
position: relative;
|
||||
min-width: var(--psp-button--min-width, 33px);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: none;
|
||||
background-color: var(--psp--color);
|
||||
color: var(--psp--background-color);
|
||||
|
||||
& > .icon {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
& > span:not(.icon) {
|
||||
/* The label. */
|
||||
display: block;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: calc(100% + 3px);
|
||||
left: 50%;
|
||||
translate: -50%;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
height: auto;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
width: 41px;
|
||||
background-color: var(--psp--color);
|
||||
border-radius: 0 0 3px 3px;
|
||||
}
|
||||
|
||||
& > .dropdown-width-container {
|
||||
display: block;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
background-color: var(--psp--color);
|
||||
pointer-events: all;
|
||||
|
||||
& > select {
|
||||
position: static;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset-block-start: -3px;
|
||||
inset-inline-start: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 3px;
|
||||
}
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes status-bar-updating {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
99% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes status-bar-updating-inverse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
99% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host #selected-columns .type-icon,
|
||||
:host #sub-columns .type-icon {
|
||||
/* position: absolute; */
|
||||
/* left: 15px; */
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
:host .icon {
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
display: inline-block;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
&.integer {
|
||||
-webkit-mask-image: var(--icon-image);
|
||||
mask-image: var(--icon-image);
|
||||
|
||||
&:before {
|
||||
content: var(--icon-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host .type-icon {
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
display: inline-block;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
|
||||
&.none {
|
||||
content: none;
|
||||
}
|
||||
|
||||
&.integer {
|
||||
-webkit-mask-image: var(--psp-column-type--integer--mask-image);
|
||||
mask-image: var(--psp-column-type--integer--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-column-type--integer--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.float {
|
||||
-webkit-mask-image: var(--psp-column-type--float--mask-image);
|
||||
mask-image: var(--psp-column-type--float--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-column-type--float--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.string {
|
||||
-webkit-mask-image: var(--psp-column-type--string--mask-image);
|
||||
mask-image: var(--psp-column-type--string--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-column-type--string--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.boolean {
|
||||
-webkit-mask-image: var(--psp-column-type--boolean--mask-image);
|
||||
mask-image: var(--psp-column-type--boolean--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-column-type--boolean--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.date {
|
||||
-webkit-mask-image: var(--psp-column-type--date--mask-image);
|
||||
mask-image: var(--psp-column-type--date--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-column-type--date--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.datetime {
|
||||
-webkit-mask-image: var(--psp-column-type--datetime--mask-image);
|
||||
mask-image: var(--psp-column-type--datetime--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-column-type--datetime--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.expression {
|
||||
-webkit-mask-image: var(--psp-icon--add-expression--mask-image);
|
||||
mask-image: var(--psp-icon--add-expression--mask-image);
|
||||
&:before {
|
||||
content: var(--psp-icon--add-expression--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
* ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
*/
|
||||
|
||||
:host {
|
||||
--settings-panel-z-index: 10;
|
||||
--psp--color: #ff0000;
|
||||
}
|
||||
|
||||
/* Shift-key affordance: while the user holds Shift, any mask-image icon
|
||||
* tagged `.shift-alt-icon` recolors to advertise that a Shift-modified
|
||||
* action is available on the underlying control. `!important` is required
|
||||
* to beat per-icon ID-scoped `background-color` rules. */
|
||||
:host(.shift-active) .shift-alt-icon {
|
||||
background-color: var(
|
||||
--shift-active--color,
|
||||
var(--psp-datagrid--pos-cell--color, #1078d1)
|
||||
) !important;
|
||||
}
|
||||
|
||||
:host(.shift-active) {
|
||||
#sub-columns {
|
||||
.is_column_active.toggle-mode {
|
||||
-webkit-mask-image: var(--psp-icon--radio-off--mask-image);
|
||||
mask-image: var(--psp-icon--radio-off--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--radio-off--mask-image);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
.is_column_active.select-mode {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-off--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-off--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--checkbox-off--mask-image);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#active-columns {
|
||||
.is_column_active.toggle-mode {
|
||||
-webkit-mask-image: var(--psp-icon--radio-on--mask-image);
|
||||
mask-image: var(--psp-icon--radio-on--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--radio-on--mask-image);
|
||||
}
|
||||
|
||||
&:not(.required):hover {
|
||||
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
mask-image: var(--psp-icon--radio-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
.is_column_active.select-mode {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-on--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--checkbox-on--mask-image);
|
||||
}
|
||||
|
||||
&:not(.required):hover {
|
||||
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
mask-image: var(--psp-icon--checkbox-hover--mask-image);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::slotted(*) {
|
||||
pointer-events: var(--override-content-pointer-events);
|
||||
}
|
||||
|
||||
:host input[type="number"]::-webkit-inner-spin-button {
|
||||
transform: scale(1.5) translateX(-5px); /* Makes the arrows 50% larger */
|
||||
filter: var(--psp-input--filter);
|
||||
}
|
||||
|
||||
:host .sidebar_close_button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
padding-top: calc(
|
||||
(
|
||||
var(
|
||||
--psp-plugin-selector--height,
|
||||
var(--psp-status-bar--height, 48px)
|
||||
) -
|
||||
20px
|
||||
) /
|
||||
2
|
||||
);
|
||||
padding-bottom: calc(
|
||||
(
|
||||
var(
|
||||
--psp-plugin-selector--height,
|
||||
var(--psp-status-bar--height, 48px)
|
||||
) -
|
||||
20px
|
||||
) /
|
||||
2
|
||||
);
|
||||
z-index: 10000;
|
||||
height: var(
|
||||
--psp-plugin-selector--height,
|
||||
var(--psp-status-bar--height, 48px)
|
||||
);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar_close_button_inner {
|
||||
border-radius: 5px 0 0 5px;
|
||||
height: 20px;
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
.sidebar_close_button_inner .icon {
|
||||
display: inline-block;
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
mask-image: var(--psp-icon--drawer-tab--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--drawer-tab--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--drawer-tab--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .sidebar_close_button_inner .icon {
|
||||
mask-image: var(--psp-icon--drawer-tab-hover--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--drawer-tab-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
:host #settings_close_button,
|
||||
:host #column_settings_close_button {
|
||||
left: -21px;
|
||||
}
|
||||
|
||||
:host #debug_close_button.sidebar_close_button .sidebar_close_button_inner {
|
||||
background-attachment: fixed;
|
||||
background: var(--psp-sidebar--background);
|
||||
|
||||
.icon {
|
||||
mask-image: var(--psp-icon--drawer-tab-hover--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--drawer-tab-hover--mask-image);
|
||||
}
|
||||
}
|
||||
|
||||
:host
|
||||
#debug_close_button.sidebar_close_button:hover
|
||||
.sidebar_close_button_inner
|
||||
.icon {
|
||||
mask-image: var(--psp-icon--drawer-tab--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--drawer-tab--mask-image);
|
||||
}
|
||||
|
||||
:host #debug_open_button.sidebar_close_button {
|
||||
right: 0px;
|
||||
cursor: pointer;
|
||||
|
||||
.sidebar_close_button_inner {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
:host #debug_open_button.sidebar_close_button .sidebar_close_button_inner {
|
||||
.icon {
|
||||
mask-image: var(--psp-icon--drawer-tab-inverted--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--drawer-tab-inverted--mask-image);
|
||||
}
|
||||
|
||||
&:hover .icon {
|
||||
mask-image: var(--psp-icon--drawer-tab-inverted-hover--mask-image);
|
||||
-webkit-mask-image: var(
|
||||
--psp-icon--drawer-tab-inverted-hover--mask-image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
:host #debug_close_button.sidebar_close_button {
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
#menu-bar {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
#component_container #settings_button,
|
||||
#component_container #settings_button.titled {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
font-size: 0.75em;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
#component_container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#app_panel {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: auto;
|
||||
flex-direction: row-reverse;
|
||||
& > .split-panel-divider {
|
||||
border-left: 1px solid var(--psp-inactive--color, #6e6e6e);
|
||||
margin-right: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
#modal_panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
& > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
#modal_panel > .split-panel-divider {
|
||||
border-left: 1px solid var(--psp-inactive--color, #6e6e6e);
|
||||
margin-right: -5px;
|
||||
}
|
||||
|
||||
#status_bar {
|
||||
height: var(--psp-status-bar--height, 48px);
|
||||
}
|
||||
|
||||
#main_column_container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#main_column {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
margin: var(--psp-main-column--margin);
|
||||
border: var(--psp-main-column--border);
|
||||
border-width: var(--psp-main-column--border-width);
|
||||
border-radius: var(--psp-main-column--border-radius);
|
||||
}
|
||||
|
||||
#main_panel_container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
box-shadow: var(--plugin--box-shadow, none);
|
||||
background: var(
|
||||
--psp-main-column--background,
|
||||
var(--psp--background-color, none)
|
||||
);
|
||||
overflow: hidden;
|
||||
border: var(--psp-plugin--border, none);
|
||||
}
|
||||
|
||||
#settings_panel {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
padding: 0pc;
|
||||
padding-left: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
z-index: var(--settings-panel-z-index);
|
||||
|
||||
& > .split-panel-child {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& > .split-panel-divider {
|
||||
border-top: 1px solid var(--psp-inactive--color, #6e6e6e);
|
||||
border-bottom: 2px solid var(--psp-inactive--color, #6e6e6e);
|
||||
margin-left: -9px;
|
||||
margin-right: 0px;
|
||||
flex: 0 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
#column_settings_sidebar .split-panel-divider {
|
||||
border-top: 1px solid var(--psp-inactive--color, #6e6e6e);
|
||||
border-bottom: 2px solid var(--psp-inactive--color, #6e6e6e);
|
||||
margin-left: -9px;
|
||||
margin-right: -4px;
|
||||
flex: 0 0 4px;
|
||||
}
|
||||
|
||||
.noselect {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar_column {
|
||||
background: var(--psp-sidebar--background);
|
||||
background-attachment: fixed;
|
||||
|
||||
display: flex;
|
||||
list-style: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
/* Needs 2 because it must take precedence over teh chart legend. */
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#plugin_selector {
|
||||
margin-top: 3px;
|
||||
border-bottom-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--psp-inactive--color, #c5c9d0);
|
||||
border-top-width: 0px;
|
||||
border-left-width: 0px;
|
||||
border-right-width: 0px;
|
||||
}
|
||||
|
||||
#component_container > #settings_button {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: var(--settings-button--margin, 10px 36px 0 0);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#close_button {
|
||||
background-color: var(--psp--background-color);
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
display: var(--psp-close-button--display, none);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
border: 1px solid var(--psp-inactive--color);
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
|
||||
&:hover {
|
||||
color: var(--psp--background-color, inherit);
|
||||
background-color: var(--psp--color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover .icon {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
mask-image: var(--psp-icon--close--mask-image);
|
||||
-webkit-mask-image: var(--psp-icon--close--mask-image);
|
||||
|
||||
&:before {
|
||||
content: var(--psp-icon--close--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#settings_button {
|
||||
background-color: var(--psp--background-color);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
border: 1px solid var(--psp-inactive--color);
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
|
||||
&:hover {
|
||||
color: var(--psp--background-color, inherit);
|
||||
background-color: var(--psp--color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover .icon {
|
||||
background-color: var(--psp--background-color);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--psp--color);
|
||||
mask-image: var(--psp-icon--drawer-tab-inverted--mask-image);
|
||||
-webkit-mask-image: var(
|
||||
--psp-icon--drawer-tab-inverted--mask-image
|
||||
);
|
||||
|
||||
&:before {
|
||||
line-height: 0;
|
||||
display: inline-flex;
|
||||
content: var(--psp-icon--drawer-tab-inverted--mask-image);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings_tab_bar_scroll_offset {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
#settings_tab_bar {
|
||||
display: flex;
|
||||
&:after {
|
||||
content: " ";
|
||||
border-bottom: 1px solid var(--psp-inactive--color);
|
||||
background-color: #00000020;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.settings_tab {
|
||||
border-right: 1px solid var(--psp-inactive--color);
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
font-size: var(--label--font-size, 0.75em);
|
||||
text-transform: uppercase;
|
||||
flex: 0 1 100px;
|
||||
background-color: #00000020;
|
||||
border-bottom: 1px solid var(--psp-inactive--color);
|
||||
color: var(--psp-inactive--color);
|
||||
margin-left: -1px;
|
||||
border-left: 1px solid var(--psp-inactive--color);
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.selected_tab {
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border-bottom: 1px solid transparent;
|
||||
box-shadow: 0px 9px 0px -8px var(--psp-inactive--color);
|
||||
height: calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
|
||||
.split-panel.orient-reverse
|
||||
> .split-panel-child:not(:last-child):not(.is-width-override) {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.sidebar_header {
|
||||
min-height: calc(
|
||||
var(
|
||||
--psp-plugin-selector--height,
|
||||
var(--psp-status-bar--height, 48px)
|
||||
) +
|
||||
1px
|
||||
);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--psp-inactive--color, #6e6e6e);
|
||||
}
|
||||
|
||||
.sidebar_header_title {
|
||||
padding-left: 9px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.sidebar_border {
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background-color: var(--psp-inactive--color, #6e6e6e);
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
#query_tabbar_tab:before {
|
||||
content: var(--psp-label--query-tab--content);
|
||||
}
|
||||
|
||||
#plugin_tabbar_tab:before {
|
||||
content: var(--psp-label--plugin-tab--content);
|
||||
}
|
||||
|
||||
#debug_tabbar_tab:before {
|
||||
content: var(--psp-label--debug-tab--content);
|
||||
}
|
||||
|
||||
label#stack-label:before {
|
||||
content: var(--psp-label--stack--content, "Stack");
|
||||
}
|
||||
|
||||
label#alt_axis-label:before {
|
||||
content: var(--psp-label--alt-axis--content, "Alt Axis");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use perspective_client::clone;
|
||||
use perspective_client::config::Expression;
|
||||
use web_sys::*;
|
||||
use yew::html::ImplicitClone;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::column_selector::InPlaceColumn;
|
||||
use super::portal::PortalModal;
|
||||
use super::style::StyleSurface;
|
||||
use crate::session::Session;
|
||||
use crate::utils::*;
|
||||
use crate::*;
|
||||
|
||||
/// Shared state for the column dropdown, updated imperatively.
|
||||
#[derive(Default)]
|
||||
pub struct ColumnDropDownState {
|
||||
pub values: Vec<InPlaceColumn>,
|
||||
pub selected: usize,
|
||||
pub width: f64,
|
||||
pub on_select: Option<Callback<InPlaceColumn>>,
|
||||
pub target: Option<HtmlElement>,
|
||||
pub no_results: bool,
|
||||
}
|
||||
|
||||
/// A clonable handle for the column dropdown shared state.
|
||||
#[derive(Clone)]
|
||||
pub struct ColumnDropDownElement {
|
||||
state: Rc<RefCell<ColumnDropDownState>>,
|
||||
session: Session,
|
||||
notify: Rc<PubSub<()>>,
|
||||
}
|
||||
|
||||
impl PartialEq for ColumnDropDownElement {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.state, &other.state)
|
||||
}
|
||||
}
|
||||
|
||||
impl ImplicitClone for ColumnDropDownElement {}
|
||||
|
||||
impl ColumnDropDownElement {
|
||||
pub fn new(session: Session) -> Self {
|
||||
Self {
|
||||
state: Default::default(),
|
||||
session,
|
||||
notify: Rc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn autocomplete(
|
||||
&self,
|
||||
target: HtmlInputElement,
|
||||
exclude: HashSet<String>,
|
||||
callback: Callback<InPlaceColumn>,
|
||||
) -> Option<()> {
|
||||
let input = target.value();
|
||||
let metadata = self.session.metadata();
|
||||
let mut values: Vec<InPlaceColumn> = vec![];
|
||||
let small_input = input.to_lowercase();
|
||||
for col in metadata.get_table_columns()? {
|
||||
if !exclude.contains(col) && col.to_lowercase().contains(&small_input) {
|
||||
values.push(InPlaceColumn::Column(col.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
for col in self.session.metadata().get_expression_columns() {
|
||||
if !exclude.contains(col) && col.to_lowercase().contains(&small_input) {
|
||||
values.push(InPlaceColumn::Column(col.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
clone!(self.state, self.session, self.notify);
|
||||
let target_elem: HtmlElement = target.clone().into();
|
||||
let width = target.get_bounding_client_rect().width();
|
||||
ApiFuture::spawn(async move {
|
||||
if !exclude.contains(&input) {
|
||||
let is_expr = crate::queries::validate_expr(&session, &input)
|
||||
.await?
|
||||
.is_none();
|
||||
if is_expr {
|
||||
values.push(InPlaceColumn::Expression(Expression::new(
|
||||
None,
|
||||
input.into(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let no_results = values.is_empty();
|
||||
{
|
||||
let mut s = state.borrow_mut();
|
||||
s.values = values;
|
||||
s.selected = 0;
|
||||
s.width = width;
|
||||
s.on_select = Some(callback);
|
||||
s.target = Some(target_elem);
|
||||
s.no_results = no_results;
|
||||
}
|
||||
notify.emit(());
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
pub fn item_select(&self) {
|
||||
let state = self.state.borrow();
|
||||
if let Some(value) = state.values.get(state.selected)
|
||||
&& let Some(ref cb) = state.on_select
|
||||
{
|
||||
cb.emit(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_down(&self) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.selected += 1;
|
||||
if state.selected >= state.values.len() {
|
||||
state.selected = 0;
|
||||
}
|
||||
|
||||
drop(state);
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
pub fn item_up(&self) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if state.selected < 1 {
|
||||
state.selected = state.values.len();
|
||||
}
|
||||
|
||||
state.selected -= 1;
|
||||
drop(state);
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
pub fn hide(&self) -> ApiResult<()> {
|
||||
self.state.borrow_mut().target = None;
|
||||
self.notify.emit(());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A portal component that renders the column dropdown. Should be placed in
|
||||
/// the view of the component that creates the `ColumnDropDownElement`.
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ColumnDropDownPortalProps {
|
||||
pub element: ColumnDropDownElement,
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
pub struct ColumnDropDownPortal {
|
||||
_sub: Subscription,
|
||||
}
|
||||
|
||||
impl Component for ColumnDropDownPortal {
|
||||
type Message = ();
|
||||
type Properties = ColumnDropDownPortalProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let link = ctx.link().clone();
|
||||
let sub = ctx
|
||||
.props()
|
||||
.element
|
||||
.notify
|
||||
.add_listener(move |()| link.send_message(()));
|
||||
Self { _sub: sub }
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let state = ctx.props().element.state.borrow();
|
||||
let target = state.target.clone();
|
||||
let on_close = {
|
||||
let element = ctx.props().element.clone();
|
||||
Callback::from(move |()| {
|
||||
let _ = element.hide();
|
||||
})
|
||||
};
|
||||
|
||||
if target.is_some() {
|
||||
let values = state.values.clone();
|
||||
let selected = state.selected;
|
||||
let width = state.width;
|
||||
let on_select = state.on_select.clone();
|
||||
drop(state);
|
||||
|
||||
html! {
|
||||
<PortalModal
|
||||
tag_name="perspective-dropdown"
|
||||
surface={StyleSurface::ColumnDropdown}
|
||||
{target}
|
||||
own_focus=false
|
||||
{on_close}
|
||||
theme={ctx.props().theme.clone()}
|
||||
>
|
||||
<ColumnDropDownView {values} {selected} {width} {on_select} />
|
||||
</PortalModal>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure view component for the column dropdown content.
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct ColumnDropDownViewProps {
|
||||
values: Vec<InPlaceColumn>,
|
||||
selected: usize,
|
||||
width: f64,
|
||||
on_select: Option<Callback<InPlaceColumn>>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn ColumnDropDownView(props: &ColumnDropDownViewProps) -> Html {
|
||||
let body = html! {
|
||||
if !props.values.is_empty() {
|
||||
{ for props.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, value)| {
|
||||
let click = props.on_select.as_ref().unwrap().reform({
|
||||
let value = value.clone();
|
||||
move |_: MouseEvent| value.clone()
|
||||
});
|
||||
|
||||
let row = match value {
|
||||
InPlaceColumn::Column(col) => html! {
|
||||
<span>{ col }</span>
|
||||
},
|
||||
InPlaceColumn::Expression(col) => html! {
|
||||
<span id="add-expression"><span class="icon" />{ col.name.clone() }</span>
|
||||
},
|
||||
};
|
||||
|
||||
html! {
|
||||
if idx == props.selected {
|
||||
<span onmousedown={click} class="selected">{ row }</span>
|
||||
} else {
|
||||
<span onmousedown={click}>{ row }</span>
|
||||
}
|
||||
}
|
||||
}) }
|
||||
} else {
|
||||
<span class="no-results" />
|
||||
}
|
||||
};
|
||||
|
||||
let position = format!(
|
||||
":host{{min-width:{}px;max-width:{}px}}",
|
||||
props.width, props.width
|
||||
);
|
||||
|
||||
html! { <><style>{ position }</style>{ body }</> }
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
mod active_column;
|
||||
mod add_expression_button;
|
||||
mod aggregate_selector;
|
||||
mod config_selector;
|
||||
mod empty_column;
|
||||
mod expr_edit_button;
|
||||
mod filter_column;
|
||||
mod inactive_column;
|
||||
mod invalid_column;
|
||||
mod pivot_column;
|
||||
mod sort_column;
|
||||
|
||||
use std::iter::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub use empty_column::*;
|
||||
pub use invalid_column::*;
|
||||
use perspective_client::config::ViewConfig;
|
||||
use perspective_js::utils::ApiFuture;
|
||||
pub use pivot_column::*;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use self::active_column::*;
|
||||
use self::add_expression_button::AddExpressionButton;
|
||||
use self::config_selector::ConfigSelector;
|
||||
use self::inactive_column::*;
|
||||
use super::containers::scroll_panel::*;
|
||||
use super::containers::split_panel::{Orientation, SplitPanel};
|
||||
use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
|
||||
use crate::components::containers::scroll_panel_item::ScrollPanelItem;
|
||||
use crate::presentation::{ColumnLocator, DragDropContainer, Presentation};
|
||||
use crate::queries::{ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet};
|
||||
use crate::renderer::*;
|
||||
use crate::session::drag_drop_update::*;
|
||||
use crate::session::*;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct ColumnSelectorProps {
|
||||
/// Fires when the expression/config column is open.
|
||||
pub on_open_expr_panel: Callback<ColumnLocator>,
|
||||
|
||||
/// This is passed to the add_expression_button for styling.
|
||||
pub selected_column: Option<ColumnLocator>,
|
||||
|
||||
/// Value props threaded from root's `SessionProps` / `RendererProps`.
|
||||
pub has_table: Option<TableLoadState>,
|
||||
pub named_column_count: usize,
|
||||
pub view_config: PtrEqRc<ViewConfig>,
|
||||
pub drag_column: Option<String>,
|
||||
|
||||
/// Cloned session metadata snapshot — threaded from `SessionProps`
|
||||
/// so that metadata changes trigger re-renders via prop diffing.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
/// Selected theme name, threaded for PortalModal consumers.
|
||||
pub selected_theme: Option<String>,
|
||||
|
||||
// State
|
||||
pub session: Session,
|
||||
pub renderer: Renderer,
|
||||
pub presentation: Presentation,
|
||||
|
||||
/// Fires when this component is resized via the UI.
|
||||
#[prop_or_default]
|
||||
pub on_resize: Option<Rc<PubSub<()>>>,
|
||||
|
||||
/// Trap-door width pinned by the parent `SettingsPanel` so switching
|
||||
/// tabs doesn't shrink the panel. Threaded into the inner
|
||||
/// `ScrollPanel` as `initial_width`.
|
||||
#[prop_or_default]
|
||||
pub initial_width: f64,
|
||||
|
||||
/// Fires when the inner `ScrollPanel` measures its natural width.
|
||||
/// Routed up to `SettingsPanel` which keeps the running max.
|
||||
#[prop_or_default]
|
||||
pub on_auto_width: Callback<f64>,
|
||||
|
||||
/// External "release the trap-door" signal from the outer settings
|
||||
/// split-panel divider reset. Forwarded into `self.on_reset` so both
|
||||
/// inner `ScrollPanel`s drop their cached `viewport_width`.
|
||||
#[prop_or_default]
|
||||
pub on_dimensions_reset: Option<Rc<PubSub<()>>>,
|
||||
}
|
||||
|
||||
impl PartialEq for ColumnSelectorProps {
|
||||
fn eq(&self, rhs: &Self) -> bool {
|
||||
self.selected_column == rhs.selected_column
|
||||
&& self.has_table == rhs.has_table
|
||||
&& self.named_column_count == rhs.named_column_count
|
||||
&& self.view_config == rhs.view_config
|
||||
&& self.drag_column == rhs.drag_column
|
||||
&& self.metadata == rhs.metadata
|
||||
&& self.selected_theme == rhs.selected_theme
|
||||
&& self.initial_width == rhs.initial_width
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ColumnSelectorMsg {
|
||||
/// Triggers a plain re-render; used as `onselect`/`ondragenter` callbacks
|
||||
/// from `ConfigSelector` after it mutates the view config.
|
||||
Redraw,
|
||||
HoverActiveIndex(Option<usize>),
|
||||
Drop((String, DragTarget, DragEffect, usize)),
|
||||
}
|
||||
|
||||
use ColumnSelectorMsg::*;
|
||||
|
||||
/// A `ColumnSelector` controls the `columns` field of the `ViewConfig`,
|
||||
/// deriving its options from the table columns and `ViewConfig` expressions.
|
||||
pub struct ColumnSelector {
|
||||
_subscriptions: Vec<Subscription>,
|
||||
drag_container: DragDropContainer,
|
||||
column_dropdown: ColumnDropDownElement,
|
||||
on_reset: Rc<PubSub<()>>,
|
||||
}
|
||||
|
||||
impl Component for ColumnSelector {
|
||||
type Message = ColumnSelectorMsg;
|
||||
type Properties = ColumnSelectorProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let ColumnSelectorProps {
|
||||
presentation,
|
||||
session,
|
||||
..
|
||||
} = ctx.props();
|
||||
|
||||
let drop_sub = {
|
||||
let cb = ctx.link().callback(ColumnSelectorMsg::Drop);
|
||||
presentation.drop_received.add_listener(cb)
|
||||
};
|
||||
|
||||
let drag_container = DragDropContainer::new(|| {}, {
|
||||
let link = ctx.link().clone();
|
||||
move || link.send_message(ColumnSelectorMsg::HoverActiveIndex(None))
|
||||
});
|
||||
|
||||
let column_dropdown = ColumnDropDownElement::new(session.clone());
|
||||
let on_reset: Rc<PubSub<()>> = Default::default();
|
||||
let mut subscriptions = vec![drop_sub];
|
||||
if let Some(outer_reset) = ctx.props().on_dimensions_reset.as_ref() {
|
||||
let on_reset = on_reset.clone();
|
||||
subscriptions.push(outer_reset.add_listener(move |()| on_reset.emit(())));
|
||||
}
|
||||
|
||||
Self {
|
||||
_subscriptions: subscriptions,
|
||||
drag_container,
|
||||
column_dropdown,
|
||||
on_reset,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Redraw => true,
|
||||
HoverActiveIndex(Some(to_index)) => ctx
|
||||
.props()
|
||||
.presentation
|
||||
.notify_drag_enter(DragTarget::Active, to_index),
|
||||
HoverActiveIndex(_) => {
|
||||
ctx.props()
|
||||
.presentation
|
||||
.notify_drag_leave(DragTarget::Active);
|
||||
true
|
||||
},
|
||||
Drop((column, DragTarget::Active, DragEffect::Move(DragTarget::Active), index)) => {
|
||||
let is_invalid = {
|
||||
let config = &ctx.props().view_config;
|
||||
let from_index = config
|
||||
.columns
|
||||
.iter()
|
||||
.position(|x| x.as_ref() == Some(&column));
|
||||
|
||||
let min_cols = ctx.props().renderer.metadata().min_config_columns;
|
||||
let is_to_empty = !config
|
||||
.columns
|
||||
.get(index)
|
||||
.map(|x| x.is_some())
|
||||
.unwrap_or_default();
|
||||
|
||||
min_cols
|
||||
.and_then(|x| from_index.map(|fi| fi < x))
|
||||
.unwrap_or_default()
|
||||
&& is_to_empty
|
||||
};
|
||||
if !is_invalid {
|
||||
let col_type = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.get_column_table_type(column.as_str())
|
||||
.unwrap();
|
||||
|
||||
let update = ctx.props().view_config.create_drag_drop_update(
|
||||
column,
|
||||
col_type,
|
||||
index,
|
||||
DragTarget::Active,
|
||||
DragEffect::Move(DragTarget::Active),
|
||||
&ctx.props().renderer.metadata(),
|
||||
ctx.props().metadata.get_features().unwrap(),
|
||||
);
|
||||
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
},
|
||||
Drop((column, DragTarget::Active, effect, index)) => {
|
||||
let col_type = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.get_column_table_type(column.as_str())
|
||||
.unwrap();
|
||||
let update = ctx.props().view_config.create_drag_drop_update(
|
||||
column,
|
||||
col_type,
|
||||
index,
|
||||
DragTarget::Active,
|
||||
effect,
|
||||
&ctx.props().renderer.metadata(),
|
||||
ctx.props().metadata.get_features().unwrap(),
|
||||
);
|
||||
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
|
||||
true
|
||||
},
|
||||
Drop((_, _, DragEffect::Move(DragTarget::Active), _)) => true,
|
||||
Drop((..)) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let ColumnSelectorProps {
|
||||
session,
|
||||
renderer,
|
||||
presentation,
|
||||
..
|
||||
} = ctx.props();
|
||||
let metadata = &ctx.props().metadata;
|
||||
|
||||
// When `config.columns` is empty but the table has columns (transient
|
||||
// state during `load()` after `reset()` clears the config), fill in
|
||||
// all table columns as active — matching `validate_view_config()`.
|
||||
let prop_config = &ctx.props().view_config;
|
||||
let config = if prop_config.columns.is_empty() {
|
||||
if let Some(table_cols) = metadata.get_table_columns() {
|
||||
ViewConfig {
|
||||
columns: table_cols.iter().map(|c| Some(c.clone())).collect(),
|
||||
..(**prop_config).clone()
|
||||
}
|
||||
.into()
|
||||
} else {
|
||||
prop_config.clone()
|
||||
}
|
||||
} else {
|
||||
prop_config.clone()
|
||||
};
|
||||
|
||||
let is_aggregated = config.is_aggregated();
|
||||
let columns_iter = ColumnsIteratorSet::new(&config, metadata, renderer, presentation);
|
||||
let onselect = ctx.link().callback(|()| Redraw);
|
||||
let ondragenter = ctx.link().callback(HoverActiveIndex);
|
||||
let ondragover = Callback::from(|_event: DragEvent| _event.prevent_default());
|
||||
let ondrop = Callback::from({
|
||||
clone!(presentation);
|
||||
move |event| presentation.notify_drop(&event)
|
||||
});
|
||||
|
||||
let ondragend = Callback::from({
|
||||
clone!(presentation);
|
||||
move |_| presentation.notify_drag_end()
|
||||
});
|
||||
|
||||
let mut active_classes = classes!("scrollable");
|
||||
if ctx.props().drag_column.is_some() {
|
||||
active_classes.push("dragdrop-highlight");
|
||||
};
|
||||
|
||||
if is_aggregated {
|
||||
active_classes.push("is-aggregated");
|
||||
}
|
||||
|
||||
let size_hint = 28.0f64.mul_add(
|
||||
(config.group_by.len()
|
||||
+ config.split_by.len()
|
||||
+ config.filter.len()
|
||||
+ config.sort.len()) as f64,
|
||||
metadata
|
||||
.get_features()
|
||||
.map(|x| {
|
||||
let mut y = 0.0;
|
||||
if !x.filter_ops.is_empty() {
|
||||
y += 1.0;
|
||||
}
|
||||
|
||||
if x.group_by {
|
||||
y += 1.0;
|
||||
}
|
||||
|
||||
if x.split_by {
|
||||
y += 1.0;
|
||||
}
|
||||
|
||||
if x.sort {
|
||||
y += 1.0;
|
||||
}
|
||||
|
||||
y * 55.0
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
let config_selector = html_nested! {
|
||||
<ScrollPanelItem key="config_selector" {size_hint}>
|
||||
<ConfigSelector
|
||||
onselect={onselect.clone()}
|
||||
ondragenter={ctx.link().callback(|()| Redraw)}
|
||||
view_config={ctx.props().view_config.clone()}
|
||||
drag_column={ctx.props().drag_column.clone()}
|
||||
metadata={metadata.clone()}
|
||||
selected_theme={ctx.props().selected_theme.clone()}
|
||||
{presentation}
|
||||
{renderer}
|
||||
{session}
|
||||
/>
|
||||
</ScrollPanelItem>
|
||||
};
|
||||
|
||||
let mut named_count = ctx.props().named_column_count;
|
||||
let mut active_columns: Vec<_> = columns_iter
|
||||
.active()
|
||||
.enumerate()
|
||||
.map(|(idx, name): (usize, ActiveColumnState)| {
|
||||
let ondragenter = ondragenter.reform(move |_| Some(idx));
|
||||
let size_hint = if named_count > 0 { 50.0 } else { 28.0 };
|
||||
named_count = named_count.saturating_sub(1);
|
||||
let key = name
|
||||
.get_name()
|
||||
.map(|x| x.to_owned())
|
||||
.unwrap_or_else(|| format!("__auto_{idx}__"));
|
||||
|
||||
let column_dropdown = self.column_dropdown.clone();
|
||||
let is_editing = matches!(
|
||||
&ctx.props().selected_column,
|
||||
Some(ColumnLocator::Table(x)) | Some(ColumnLocator::Expression(x))
|
||||
if x == &key );
|
||||
|
||||
// Compute metadata-derived props here so that changes to
|
||||
// session metadata propagate via prop diffing.
|
||||
// For DragOver placeholders, resolve the type from the
|
||||
// dragged column (since `get_name()` returns `None`).
|
||||
let col_type = name
|
||||
.get_name()
|
||||
.and_then(|n| metadata.get_column_table_type(n))
|
||||
.or_else(|| {
|
||||
if matches!(name.state, ActiveColumnStateData::DragOver) {
|
||||
presentation
|
||||
.get_drag_column()
|
||||
.and_then(|c| metadata.get_column_table_type(&c))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let is_expression = name
|
||||
.get_name()
|
||||
.map(|n| metadata.is_column_expression(n))
|
||||
.unwrap_or(false);
|
||||
|
||||
let can_render_styles =
|
||||
name.get_name().is_some() && renderer.can_render_column_styles();
|
||||
|
||||
let show_edit_btn = is_expression || can_render_styles;
|
||||
let on_open_expr_panel = &ctx.props().on_open_expr_panel;
|
||||
html_nested! {
|
||||
<ScrollPanelItem {key} {size_hint}>
|
||||
<ActiveColumn
|
||||
{column_dropdown}
|
||||
{idx}
|
||||
{is_aggregated}
|
||||
{is_editing}
|
||||
{is_expression}
|
||||
{show_edit_btn}
|
||||
{col_type}
|
||||
view_config={config.clone()}
|
||||
metadata={metadata.clone()}
|
||||
{name}
|
||||
{on_open_expr_panel}
|
||||
{ondragenter}
|
||||
ondragend={&ondragend}
|
||||
onselect={&onselect}
|
||||
{presentation}
|
||||
{renderer}
|
||||
{session}
|
||||
/>
|
||||
</ScrollPanelItem>
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut inactive_children: Vec<_> = columns_iter
|
||||
.expression()
|
||||
.chain(columns_iter.inactive())
|
||||
.enumerate()
|
||||
.map(|(idx, vc)| {
|
||||
let selected_column = ctx.props().selected_column.as_ref();
|
||||
let is_editing = matches!(selected_column, Some(ColumnLocator::Expression(x)) if x.as_str() == vc.name);
|
||||
let is_expression = metadata.is_column_expression(vc.name);
|
||||
html_nested! {
|
||||
<ScrollPanelItem key={vc.name} size_hint=28.0>
|
||||
<InactiveColumn
|
||||
{idx}
|
||||
visible={vc.is_visible}
|
||||
name={vc.name.to_owned()}
|
||||
{is_editing}
|
||||
{is_expression}
|
||||
view_config={config.clone()}
|
||||
metadata={metadata.clone()}
|
||||
onselect={&onselect}
|
||||
ondragend={&ondragend}
|
||||
on_open_expr_panel={&ctx.props().on_open_expr_panel}
|
||||
{presentation}
|
||||
{renderer}
|
||||
{session}
|
||||
/>
|
||||
</ScrollPanelItem>
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let size = 28.0;
|
||||
|
||||
let add_column = if metadata.get_features().unwrap().expressions {
|
||||
html_nested! {
|
||||
<ScrollPanelItem key="__add_expression__" size_hint={size}>
|
||||
<AddExpressionButton
|
||||
on_open_expr_panel={&ctx.props().on_open_expr_panel}
|
||||
selected_column={ctx.props().selected_column.clone()}
|
||||
/>
|
||||
</ScrollPanelItem>
|
||||
}
|
||||
} else {
|
||||
html_nested! {
|
||||
<ScrollPanelItem key="__add_expression__" size_hint=0_f64><span /></ScrollPanelItem>
|
||||
}
|
||||
};
|
||||
|
||||
if inactive_children.is_empty() {
|
||||
active_columns.push(add_column)
|
||||
} else {
|
||||
inactive_children.insert(0, add_column);
|
||||
}
|
||||
|
||||
let mut selected_columns = vec![html! {
|
||||
<div id="selected-columns" key="__active_columns__">
|
||||
<ScrollPanel
|
||||
id="active-columns"
|
||||
omit_autosize_div={true}
|
||||
class={active_classes}
|
||||
dragover={ondragover}
|
||||
dragenter={&self.drag_container.dragenter}
|
||||
dragleave={&self.drag_container.dragleave}
|
||||
viewport_ref={&self.drag_container.noderef}
|
||||
initial_width={ctx.props().initial_width}
|
||||
on_auto_width={ctx.props().on_auto_width.clone()}
|
||||
drop={ondrop}
|
||||
on_resize={&ctx.props().on_resize}
|
||||
on_dimensions_reset={&self.on_reset}
|
||||
children={std::iter::once(config_selector).chain(active_columns).collect::<Vec<_>>()}
|
||||
/>
|
||||
</div>
|
||||
}];
|
||||
|
||||
if !inactive_children.is_empty() {
|
||||
selected_columns.push(html! {
|
||||
<ScrollPanel
|
||||
id="sub-columns"
|
||||
key="__sub_columns__"
|
||||
class={classes!("scrollable")}
|
||||
on_resize={&ctx.props().on_resize}
|
||||
on_dimensions_reset={&self.on_reset}
|
||||
children={inactive_children}
|
||||
/>
|
||||
})
|
||||
}
|
||||
|
||||
html! {
|
||||
<>
|
||||
<SplitPanel
|
||||
no_wrap=true
|
||||
on_reset={self.on_reset.callback()}
|
||||
skip_empty=true
|
||||
orientation={Orientation::Vertical}
|
||||
>
|
||||
{ for selected_columns }
|
||||
</SplitPanel>
|
||||
<ColumnDropDownPortal
|
||||
element={self.column_dropdown.clone()}
|
||||
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use perspective_client::config::*;
|
||||
use perspective_js::utils::ApiFuture;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::InPlaceColumn;
|
||||
use super::aggregate_selector::*;
|
||||
use super::expr_edit_button::*;
|
||||
use crate::components::column_dropdown::ColumnDropDownElement;
|
||||
use crate::components::column_selector::{EmptyColumn, InvalidColumn};
|
||||
use crate::components::type_icon::TypeIcon;
|
||||
use crate::config::ColumnSelectMode;
|
||||
use crate::presentation::{ColumnLocator, Presentation};
|
||||
use crate::queries::*;
|
||||
use crate::renderer::*;
|
||||
use crate::session::*;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Clone, Properties)]
|
||||
pub struct ActiveColumnProps {
|
||||
/// The column's index in the list.
|
||||
pub idx: usize,
|
||||
|
||||
/// The column definition (including name and type).
|
||||
pub name: ActiveColumnState,
|
||||
|
||||
/// The column select dropdown menu element.
|
||||
pub column_dropdown: ColumnDropDownElement,
|
||||
|
||||
/// `dragenter` event.
|
||||
pub ondragenter: Callback<()>,
|
||||
|
||||
/// `dragend` event.
|
||||
pub ondragend: Callback<()>,
|
||||
|
||||
/// Fires when this component's select button is clicked.
|
||||
pub onselect: Callback<()>,
|
||||
|
||||
/// Fires when this component's expression/config button is clicked.
|
||||
pub on_open_expr_panel: Callback<ColumnLocator>,
|
||||
|
||||
/// Is this column in a grouped context (does the aggregate selector
|
||||
/// need to be visible)?
|
||||
#[prop_or_default]
|
||||
pub is_aggregated: bool,
|
||||
|
||||
/// Is this column's expression/config side panel open?
|
||||
pub is_editing: bool,
|
||||
|
||||
/// Whether this column is an expression column. Computed by the parent
|
||||
/// so that changes to session metadata trigger a re-render via prop diff.
|
||||
#[prop_or_default]
|
||||
pub is_expression: bool,
|
||||
|
||||
/// Whether the expression/config edit button should be shown. Computed
|
||||
/// by the parent (`is_expression || can_render_column_styles`).
|
||||
#[prop_or_default]
|
||||
pub show_edit_btn: bool,
|
||||
|
||||
/// The resolved table column type, if available. Computed by the parent
|
||||
/// from session metadata so that metadata updates trigger re-renders.
|
||||
#[prop_or_default]
|
||||
pub col_type: Option<ColumnType>,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
/// View config snapshot — threaded from parent as a value prop.
|
||||
pub view_config: PtrEqRc<ViewConfig>,
|
||||
|
||||
/// State
|
||||
pub session: Session,
|
||||
pub presentation: Presentation,
|
||||
pub renderer: Renderer,
|
||||
}
|
||||
|
||||
impl PartialEq for ActiveColumnProps {
|
||||
fn eq(&self, rhs: &Self) -> bool {
|
||||
self.idx == rhs.idx
|
||||
&& self.name == rhs.name
|
||||
&& self.is_aggregated == rhs.is_aggregated
|
||||
&& self.is_editing == rhs.is_editing
|
||||
&& self.is_expression == rhs.is_expression
|
||||
&& self.show_edit_btn == rhs.show_edit_btn
|
||||
&& self.col_type == rhs.col_type
|
||||
&& self.metadata == rhs.metadata
|
||||
&& self.view_config == rhs.view_config
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ActiveColumnMsg {
|
||||
DeactivateColumn(String, bool),
|
||||
MouseEnter(bool),
|
||||
MouseLeave(bool),
|
||||
New(InPlaceColumn),
|
||||
}
|
||||
|
||||
use ActiveColumnMsg::*;
|
||||
|
||||
/// An [`ActiveColumn`] indicates a column which is part of the `columns` field
|
||||
/// of a [`ViewConfig`]. It shows additional column details in context (like
|
||||
/// selected aggregate), and supports drag/drop and missing entries.
|
||||
/// TODO Break this into "Active", "Hover" and "Empty"?
|
||||
pub struct ActiveColumn {
|
||||
add_expression_ref: NodeRef,
|
||||
mouseover: bool,
|
||||
}
|
||||
|
||||
impl Component for ActiveColumn {
|
||||
type Message = ActiveColumnMsg;
|
||||
type Properties = ActiveColumnProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
add_expression_ref: NodeRef::default(),
|
||||
mouseover: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: ActiveColumnMsg) -> bool {
|
||||
match msg {
|
||||
DeactivateColumn(column, shift_key) => {
|
||||
ctx.props().deactivate_column(column, shift_key);
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
MouseEnter(is_render) => {
|
||||
self.mouseover = is_render;
|
||||
is_render
|
||||
},
|
||||
MouseLeave(is_render) => {
|
||||
self.mouseover = false;
|
||||
is_render
|
||||
},
|
||||
New(InPlaceColumn::Column(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
if ctx.props().idx >= view_config.columns.len() {
|
||||
view_config.columns.push(Some(col));
|
||||
} else {
|
||||
view_config.columns[ctx.props().idx] = Some(col);
|
||||
}
|
||||
|
||||
let update = ViewConfigUpdate {
|
||||
columns: Some(view_config.columns),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
},
|
||||
New(InPlaceColumn::Expression(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
if ctx.props().idx >= view_config.columns.len() {
|
||||
view_config.columns.push(Some(col.name.as_ref().to_owned()));
|
||||
} else {
|
||||
view_config.columns[ctx.props().idx] = Some(col.name.as_ref().to_owned());
|
||||
}
|
||||
|
||||
view_config.expressions.insert(&col);
|
||||
let update = ViewConfigUpdate {
|
||||
columns: Some(view_config.columns),
|
||||
expressions: Some(view_config.expressions),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
enum ColumnState {
|
||||
Empty,
|
||||
Invalid,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
let mut classes = classes!["column-selector-draggable"];
|
||||
if ctx.props().is_aggregated {
|
||||
classes.push("show-aggregate");
|
||||
};
|
||||
|
||||
let mut outer_classes = classes!["column-selector-column"];
|
||||
if self.mouseover {
|
||||
outer_classes.push("dragdrop-hover");
|
||||
}
|
||||
|
||||
let name = match &ctx.props().name {
|
||||
ActiveColumnState {
|
||||
label,
|
||||
state: ActiveColumnStateData::DragOver,
|
||||
} => {
|
||||
classes.push("dragover");
|
||||
outer_classes.push("dragover-container");
|
||||
classes.push("empty-named");
|
||||
|
||||
(
|
||||
label.clone(),
|
||||
ColumnState::Named(ctx.props().presentation.get_drag_column().unwrap()),
|
||||
)
|
||||
},
|
||||
ActiveColumnState {
|
||||
label,
|
||||
state: ActiveColumnStateData::Column(name),
|
||||
} => (label.clone(), ColumnState::Named(name.to_owned())),
|
||||
ActiveColumnState {
|
||||
label,
|
||||
state: ActiveColumnStateData::Required,
|
||||
} => (label.clone(), ColumnState::Empty),
|
||||
ActiveColumnState {
|
||||
label,
|
||||
state: ActiveColumnStateData::Invalid,
|
||||
} => (label.clone(), ColumnState::Invalid),
|
||||
};
|
||||
|
||||
let ondragenter = ctx.props().ondragenter.reform(move |event: DragEvent| {
|
||||
// Safari does not set `relatedTarget` so this event must be allowed to
|
||||
// bubble so we can count entry/exit stacks to determine true
|
||||
// `"dragleave"`.
|
||||
if event.related_target().is_some() {
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
}
|
||||
});
|
||||
|
||||
let path: String = name
|
||||
.0
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.map(|x| {
|
||||
if x.is_alphanumeric() {
|
||||
x.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let col_type = ctx.props().col_type;
|
||||
match (name, col_type) {
|
||||
((label, ColumnState::Empty), _) => {
|
||||
classes.push("empty-named");
|
||||
let column_dropdown = ctx.props().column_dropdown.clone();
|
||||
let on_select = ctx.link().callback(ActiveColumnMsg::New);
|
||||
let exclude = ctx
|
||||
.props()
|
||||
.view_config
|
||||
.columns
|
||||
.iter()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={outer_classes}
|
||||
data-label={label}
|
||||
style={format!("--default-column-title:var(--column-selector-column-{path}--content)")}
|
||||
data-index={ctx.props().idx.to_string()}
|
||||
ondragenter={ondragenter.clone()}
|
||||
>
|
||||
<EmptyColumn {column_dropdown} {exclude} {on_select} />
|
||||
</div>
|
||||
}
|
||||
},
|
||||
((label, ColumnState::Invalid), _) => {
|
||||
classes.push("empty-named");
|
||||
html! {
|
||||
<div
|
||||
class={outer_classes}
|
||||
data-label={label}
|
||||
style={format!("--default-column-title:var(--column-selector-column-{path}--content)")}
|
||||
data-index={ctx.props().idx.to_string()}
|
||||
ondragenter={ondragenter.clone()}
|
||||
>
|
||||
<InvalidColumn />
|
||||
</div>
|
||||
}
|
||||
},
|
||||
((label, ColumnState::Named(name)), Some(col_type)) => {
|
||||
let is_required = ctx.props().get_is_required(ctx.props().idx);
|
||||
let remove_column = if is_required {
|
||||
None
|
||||
} else {
|
||||
Some(ctx.link().callback({
|
||||
let event_name = name.to_owned();
|
||||
move |event: MouseEvent| {
|
||||
ActiveColumnMsg::DeactivateColumn(
|
||||
event_name.to_owned(),
|
||||
event.shift_key(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
let ondragend = &ctx.props().ondragend.reform(|_| ());
|
||||
let ondragstart = ctx.link().callback({
|
||||
let event_name = name.to_owned();
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
move |event: DragEvent| {
|
||||
presentation.set_drag_image(&event).unwrap();
|
||||
presentation.notify_drag_start(
|
||||
event_name.to_string(),
|
||||
DragEffect::Move(DragTarget::Active),
|
||||
);
|
||||
|
||||
MouseLeave(false)
|
||||
}
|
||||
});
|
||||
|
||||
let onmouseout = ctx.link().callback(|_| MouseLeave(true));
|
||||
let onmouseover = ctx
|
||||
.link()
|
||||
.callback(|event: MouseEvent| MouseEnter(event.which() == 0));
|
||||
|
||||
let is_expression = ctx.props().is_expression;
|
||||
let show_edit_btn = ctx.props().show_edit_btn;
|
||||
let mut class = ctx.props().renderer.metadata().select_mode.css();
|
||||
if is_required {
|
||||
class.push("required");
|
||||
};
|
||||
if !is_required {
|
||||
class.push("shift-alt-icon");
|
||||
}
|
||||
html! {
|
||||
<div
|
||||
class={outer_classes}
|
||||
data-label={label}
|
||||
style={format!("--default-column-title:var(--column-selector-column-{path}--content)")}
|
||||
data-index={ctx.props().idx.to_string()}
|
||||
{onmouseover}
|
||||
{onmouseout}
|
||||
ondragenter={ondragenter.clone()}
|
||||
>
|
||||
<span {class} onmousedown={remove_column} />
|
||||
<div
|
||||
class={classes}
|
||||
ref={&self.add_expression_ref}
|
||||
draggable="true"
|
||||
{ondragstart}
|
||||
{ondragend}
|
||||
>
|
||||
<div class="column-selector-column-border">
|
||||
<span class="drag-handle icon" />
|
||||
<TypeIcon ty={col_type} />
|
||||
if ctx.props().is_aggregated {
|
||||
<AggregateSelector
|
||||
column={name.clone()}
|
||||
aggregate={ctx.props().get_aggregate(&name)}
|
||||
view_config={ctx.props().view_config.clone()}
|
||||
metadata={ctx.props().metadata.clone()}
|
||||
renderer={&ctx.props().renderer}
|
||||
session={&ctx.props().session}
|
||||
/>
|
||||
}
|
||||
<span
|
||||
class="column_name"
|
||||
>
|
||||
{ name.clone() }
|
||||
</span>
|
||||
if !ctx.props().is_aggregated {
|
||||
<span class="column-selector--spacer" />
|
||||
}
|
||||
<ExprEditButton
|
||||
name={name.clone()}
|
||||
on_open_expr_panel={&ctx.props().on_open_expr_panel}
|
||||
{is_expression}
|
||||
is_disabled={!show_edit_btn}
|
||||
is_editing={ctx.props().is_editing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// Expression columns are the only UI element which requires the
|
||||
// `View` (for its expression type), we may need to stub these
|
||||
// columns out until the new View forces a re-render (and the
|
||||
// `change()` method on this component checks for this).
|
||||
|
||||
html! {
|
||||
<div class="column-selector-column">
|
||||
<span class="is_column_active inactive" />
|
||||
<div class={classes} />
|
||||
</div>
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveColumnProps {
|
||||
/// Remove an active column from `columns`, or alternatively make this
|
||||
/// column the only column in `columns` if the shift key is set (via the
|
||||
/// `shift` flag).
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `name` The name of the column to de-activate, which is a unique ID
|
||||
/// with respect to `columns`.
|
||||
/// - `shift` whether to toggle or select this column.
|
||||
fn deactivate_column(&self, name: String, shift: bool) {
|
||||
let mut columns = self.view_config.columns.clone();
|
||||
let max_cols = self.renderer.metadata().config_column_names.len();
|
||||
|
||||
match self.renderer.metadata().select_mode {
|
||||
ColumnSelectMode::Toggle => {
|
||||
let index = columns
|
||||
.iter()
|
||||
.position(|x| x.as_ref() == Some(&name))
|
||||
.unwrap();
|
||||
|
||||
if max_cols > 0 && index < max_cols - 1 {
|
||||
columns[index] = None;
|
||||
} else if !shift && columns.len() > 1 {
|
||||
columns.retain(|x| x.as_ref() != Some(&name));
|
||||
} else if shift {
|
||||
columns.clear();
|
||||
columns.push(Some(name));
|
||||
}
|
||||
},
|
||||
ColumnSelectMode::Select => {
|
||||
columns.retain(|x| x.as_ref() != Some(&name));
|
||||
},
|
||||
}
|
||||
self.apply_columns(columns);
|
||||
}
|
||||
|
||||
fn get_is_required(&self, idx: usize) -> bool {
|
||||
let min_cols = self.renderer.metadata().min_config_columns.unwrap_or(0);
|
||||
idx < min_cols
|
||||
}
|
||||
|
||||
fn get_aggregate(&self, name: &str) -> Option<Aggregate> {
|
||||
self.view_config.aggregates.get(name).cloned()
|
||||
}
|
||||
|
||||
fn apply_columns(&self, columns: Vec<Option<String>>) {
|
||||
let config = ViewConfigUpdate {
|
||||
columns: Some(columns),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
if self.session.update_view_config(config).is_ok() {
|
||||
let session = self.session.clone();
|
||||
let renderer = self.renderer.clone();
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::ColumnLocator;
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct AddExpressionButtonProps {
|
||||
// Fires when this button is clicked.
|
||||
pub on_open_expr_panel: Callback<ColumnLocator>,
|
||||
|
||||
/// The column this butotn will open configuration for.
|
||||
pub selected_column: Option<ColumnLocator>,
|
||||
}
|
||||
|
||||
/// `onmouseover` is triggered incorrectly on the `DragTarget` of a
|
||||
/// drag/drop action after `DropEvent` has fired on the `DropTarget`,
|
||||
/// causing brief hover effects where the mouse _was_ before the action
|
||||
/// initiated. Various methods of correcting this were attempted, settling
|
||||
/// on a manual `dragdrop-hover` class toggle, using the `.button()` property
|
||||
/// to determine the mis-fired event. This was determined experimentally -
|
||||
/// according to my read of the spec, this is a bug in Chrome.
|
||||
///
|
||||
/// As a result there are 3 hover states `MouseEnter(true)`,
|
||||
/// `MouseEnter(false)`, and `MouseLeave`; `MouseEnter(false)` can be
|
||||
/// replaced, but it causes an extra render of the DOM un-necessarily.
|
||||
#[function_component]
|
||||
pub fn AddExpressionButton(p: &AddExpressionButtonProps) -> Html {
|
||||
let is_mouseover = yew::use_state_eq(|| false);
|
||||
let onmouseover = yew::use_callback(is_mouseover.setter(), |event: MouseEvent, mo| {
|
||||
mo.set(event.button() == 0);
|
||||
});
|
||||
|
||||
let onmouseout = yew::use_callback(is_mouseover.setter(), |_event, mo| {
|
||||
mo.set(false);
|
||||
});
|
||||
|
||||
let onmousedown = p
|
||||
.on_open_expr_panel
|
||||
.reform(|_| ColumnLocator::NewExpression);
|
||||
|
||||
let class = if *is_mouseover || matches!(p.selected_column, Some(ColumnLocator::NewExpression))
|
||||
{
|
||||
classes!("dragdrop-hover")
|
||||
} else {
|
||||
classes!()
|
||||
};
|
||||
|
||||
html! {
|
||||
<div id="add-expression" data-index="-1" {class} {onmouseover} {onmouseout} {onmousedown}>
|
||||
<span class="icon" />
|
||||
<span id="add-expression-button" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use perspective_client::config::*;
|
||||
use perspective_js::utils::ApiFuture;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::containers::select::*;
|
||||
use crate::renderer::*;
|
||||
use crate::session::*;
|
||||
use crate::utils::PtrEqRc;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct AggregateSelectorProps {
|
||||
/// The name of this aggregate.
|
||||
pub column: String,
|
||||
|
||||
/// Which aggregate is currently selected.
|
||||
pub aggregate: Option<Aggregate>,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
/// View config snapshot — threaded from parent as a value prop.
|
||||
pub view_config: PtrEqRc<ViewConfig>,
|
||||
|
||||
// State
|
||||
pub renderer: Renderer,
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
impl PartialEq for AggregateSelectorProps {
|
||||
fn eq(&self, rhs: &Self) -> bool {
|
||||
self.column == rhs.column
|
||||
&& self.aggregate == rhs.aggregate
|
||||
&& self.metadata == rhs.metadata
|
||||
&& self.view_config == rhs.view_config
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AggregateSelectorMsg {
|
||||
SetAggregate(Aggregate),
|
||||
}
|
||||
|
||||
pub struct AggregateSelector {
|
||||
aggregates: Rc<Vec<SelectItem<Aggregate>>>,
|
||||
aggregate: Option<Aggregate>,
|
||||
}
|
||||
|
||||
impl Component for AggregateSelector {
|
||||
type Message = AggregateSelectorMsg;
|
||||
type Properties = AggregateSelectorProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut selector = Self {
|
||||
aggregates: Rc::new(vec![]),
|
||||
aggregate: ctx.props().aggregate.clone(),
|
||||
};
|
||||
|
||||
selector.aggregates = Rc::new(selector.get_dropdown_aggregates(ctx));
|
||||
selector
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
AggregateSelectorMsg::SetAggregate(aggregate) => {
|
||||
self.set_aggregate(ctx, aggregate);
|
||||
false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
self.aggregates = Rc::new(self.get_dropdown_aggregates(ctx));
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let callback = ctx.link().callback(AggregateSelectorMsg::SetAggregate);
|
||||
let selected_agg = ctx
|
||||
.props()
|
||||
.aggregate
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
ctx.props()
|
||||
.metadata
|
||||
.get_column_table_type(&ctx.props().column)
|
||||
.and_then(|x| {
|
||||
ctx.props().metadata.get_features().and_then(|y| {
|
||||
y.aggregates.get(&(x as u32)).and_then(|z| {
|
||||
z.aggregates
|
||||
.first()
|
||||
.map(|q| Aggregate::SingleAggregate(q.name.clone()))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| Aggregate::SingleAggregate("".to_string()));
|
||||
|
||||
let values = self.aggregates.clone();
|
||||
let label = ctx.props().aggregate.as_ref().map(|x| match x {
|
||||
Aggregate::SingleAggregate(_) => "".to_string(),
|
||||
Aggregate::MultiAggregate(x, _) => x.to_string(),
|
||||
});
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div class="aggregate-selector-wrapper">
|
||||
<Select<Aggregate>
|
||||
wrapper_class="aggregate-selector"
|
||||
{values}
|
||||
label={label.map(|x| x.into())}
|
||||
selected={selected_agg}
|
||||
on_select={callback}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AggregateSelector {
|
||||
pub fn set_aggregate(&mut self, ctx: &Context<Self>, aggregate: Aggregate) {
|
||||
self.aggregate = Some(aggregate.clone());
|
||||
let mut aggregates = ctx.props().view_config.aggregates.clone();
|
||||
aggregates.insert(ctx.props().column.clone(), aggregate);
|
||||
let config = ViewConfigUpdate {
|
||||
aggregates: Some(aggregates),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(config).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_dropdown_aggregates(&self, ctx: &Context<Self>) -> Vec<SelectItem<Aggregate>> {
|
||||
let aggregates = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.get_column_aggregates(&ctx.props().column)
|
||||
.map(|x| x.collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
let multi_aggregates2 = aggregates
|
||||
.clone()
|
||||
.into_iter()
|
||||
.flat_map(|x| match x {
|
||||
Aggregate::MultiAggregate(x, _) => Some(x),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
SelectItem::OptGroup(
|
||||
x.clone().into(),
|
||||
aggregates
|
||||
.iter()
|
||||
.filter(|y| {
|
||||
matches!(
|
||||
y,
|
||||
Aggregate::MultiAggregate(z, _) if &x == z
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let s = aggregates
|
||||
.iter()
|
||||
.filter(|x| matches!(x, Aggregate::SingleAggregate(_)))
|
||||
.cloned()
|
||||
.map(SelectItem::Option)
|
||||
.chain(multi_aggregates2);
|
||||
|
||||
s.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use perspective_client::config::{ViewConfig, *};
|
||||
use perspective_js::utils::ApiFuture;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::InPlaceColumn;
|
||||
use super::filter_column::*;
|
||||
use super::pivot_column::*;
|
||||
use super::sort_column::*;
|
||||
use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
|
||||
use crate::components::containers::dragdrop_list::*;
|
||||
use crate::components::containers::select::{Select, SelectItem};
|
||||
use crate::components::filter_dropdown::{FilterDropDownElement, FilterDropDownPortal};
|
||||
use crate::presentation::Presentation;
|
||||
use crate::renderer::*;
|
||||
use crate::session::drag_drop_update::*;
|
||||
use crate::session::*;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Clone, Properties)]
|
||||
pub struct ConfigSelectorProps {
|
||||
pub onselect: Callback<()>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub ondragenter: Callback<()>,
|
||||
|
||||
/// Current view config threaded as a value prop so that config changes
|
||||
/// (group_by, sort, filter, etc.) trigger re-renders via normal prop
|
||||
/// diffing rather than a PubSub `view_created` subscription.
|
||||
pub view_config: PtrEqRc<ViewConfig>,
|
||||
/// Column currently being dragged — threaded to show `dragdrop-highlight`
|
||||
/// without subscribing to `dragstart_received`/`dragend_received`.
|
||||
pub drag_column: Option<String>,
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
/// Selected theme name, threaded for PortalModal consumers.
|
||||
pub selected_theme: Option<String>,
|
||||
|
||||
// State
|
||||
pub session: Session,
|
||||
pub renderer: Renderer,
|
||||
pub presentation: Presentation,
|
||||
}
|
||||
|
||||
impl PartialEq for ConfigSelectorProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.view_config == other.view_config
|
||||
&& self.drag_column == other.drag_column
|
||||
&& self.metadata == other.metadata
|
||||
&& self.selected_theme == other.selected_theme
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigSelectorMsg {
|
||||
DragOver(usize, DragTarget),
|
||||
DragLeave(DragTarget),
|
||||
Drop(String, DragTarget, DragEffect, usize),
|
||||
Close(usize, DragTarget),
|
||||
SetFilterValue(usize, String),
|
||||
TransposePivots,
|
||||
New(DragTarget, InPlaceColumn),
|
||||
UpdateGroupRollupMode(GroupRollupMode),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigSelector {
|
||||
filter_dropdown: FilterDropDownElement,
|
||||
column_dropdown: ColumnDropDownElement,
|
||||
_subscriptions: [Rc<Subscription>; 1],
|
||||
}
|
||||
|
||||
impl Component for ConfigSelector {
|
||||
type Message = ConfigSelectorMsg;
|
||||
type Properties = ConfigSelectorProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let cb = ctx
|
||||
.link()
|
||||
.callback(|x: (String, DragTarget, DragEffect, usize)| {
|
||||
ConfigSelectorMsg::Drop(x.0, x.1, x.2, x.3)
|
||||
});
|
||||
let drop_sub = Rc::new(ctx.props().presentation.drop_received.add_listener(cb));
|
||||
|
||||
let filter_dropdown = FilterDropDownElement::new(ctx.props().session.clone());
|
||||
let column_dropdown = ColumnDropDownElement::new(ctx.props().session.clone());
|
||||
let _subscriptions = [drop_sub];
|
||||
Self {
|
||||
filter_dropdown,
|
||||
column_dropdown,
|
||||
_subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ConfigSelectorMsg::DragOver(index, action) => {
|
||||
let should_render = ctx.props().presentation.notify_drag_enter(action, index);
|
||||
if should_render {
|
||||
ctx.props().ondragenter.emit(());
|
||||
}
|
||||
should_render
|
||||
},
|
||||
ConfigSelectorMsg::DragLeave(action) => {
|
||||
ctx.props().presentation.notify_drag_leave(action);
|
||||
true
|
||||
},
|
||||
ConfigSelectorMsg::Close(index, DragTarget::Sort) => {
|
||||
let mut sort = ctx.props().view_config.sort.clone();
|
||||
sort.remove(index);
|
||||
let sort = Some(sort);
|
||||
let config = ViewConfigUpdate {
|
||||
sort,
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(config).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::UpdateGroupRollupMode(mode) => {
|
||||
let config = ViewConfigUpdate {
|
||||
group_rollup_mode: Some(mode),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(config).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::Close(index, DragTarget::GroupBy) => {
|
||||
if ctx.props().view_config.group_rollup_mode == GroupRollupMode::Total {
|
||||
let requirements = ctx.props().renderer.metadata();
|
||||
|
||||
let rollup_features = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.get_features()
|
||||
.map(|x| x.get_group_rollup_modes())
|
||||
.unwrap();
|
||||
|
||||
let group_rollups = requirements.get_group_rollups(&rollup_features);
|
||||
|
||||
ctx.link()
|
||||
.send_message(ConfigSelectorMsg::UpdateGroupRollupMode(
|
||||
group_rollups.first().cloned().unwrap(),
|
||||
));
|
||||
false
|
||||
} else {
|
||||
let mut group_by = ctx.props().view_config.group_by.clone();
|
||||
group_by.remove(index);
|
||||
let config = ViewConfigUpdate {
|
||||
group_by: Some(group_by),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(config).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
}
|
||||
},
|
||||
ConfigSelectorMsg::Close(index, DragTarget::SplitBy) => {
|
||||
let mut split_by = ctx.props().view_config.split_by.clone();
|
||||
split_by.remove(index);
|
||||
let config = ViewConfigUpdate {
|
||||
split_by: Some(split_by),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(config).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::Close(index, DragTarget::Filter) => {
|
||||
self.filter_dropdown.hide().unwrap();
|
||||
let mut filter = ctx.props().view_config.filter.clone();
|
||||
filter.remove(index);
|
||||
let config = ViewConfigUpdate {
|
||||
filter: Some(filter),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(config).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::Close(..) => false,
|
||||
ConfigSelectorMsg::Drop(column, action, effect, index)
|
||||
if action != DragTarget::Active =>
|
||||
{
|
||||
let col_type = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.get_column_table_type(column.as_str())
|
||||
.unwrap();
|
||||
let update = ctx.props().view_config.create_drag_drop_update(
|
||||
column,
|
||||
col_type,
|
||||
index,
|
||||
action,
|
||||
effect,
|
||||
&ctx.props().renderer.metadata(),
|
||||
ctx.props().metadata.get_features().unwrap(),
|
||||
);
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::Drop(_, _, DragEffect::Move(action), _)
|
||||
if action != DragTarget::Active =>
|
||||
{
|
||||
true
|
||||
},
|
||||
ConfigSelectorMsg::Drop(..) => false,
|
||||
ConfigSelectorMsg::TransposePivots => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
std::mem::swap(&mut view_config.group_by, &mut view_config.split_by);
|
||||
|
||||
let update = ViewConfigUpdate {
|
||||
group_by: Some(view_config.group_by),
|
||||
split_by: Some(view_config.split_by),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
|
||||
ConfigSelectorMsg::SetFilterValue(index, input) => {
|
||||
let mut filter = ctx.props().view_config.filter.clone();
|
||||
|
||||
// TODO Can't special case these - need to make this part of the
|
||||
// Features API.
|
||||
let update = if filter[index].op() == "in" || filter[index].op() == "not in" {
|
||||
let current = filter[index].term().to_string();
|
||||
let mut tokens = current.split(',').collect::<Vec<_>>();
|
||||
tokens.pop();
|
||||
tokens.push(&input);
|
||||
*filter[index].term_mut() = FilterTerm::Array(
|
||||
tokens
|
||||
.iter()
|
||||
.map(|x| Scalar::String(x.trim().to_owned()))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let filter = Some(filter);
|
||||
ViewConfigUpdate {
|
||||
filter,
|
||||
..ViewConfigUpdate::default()
|
||||
}
|
||||
} else {
|
||||
*filter[index].term_mut() = FilterTerm::Scalar(Scalar::String(input));
|
||||
let filter = Some(filter);
|
||||
ViewConfigUpdate {
|
||||
filter,
|
||||
..ViewConfigUpdate::default()
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::GroupBy, InPlaceColumn::Column(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
view_config.group_by.push(col);
|
||||
let update = ViewConfigUpdate {
|
||||
group_by: Some(view_config.group_by),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::SplitBy, InPlaceColumn::Column(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
view_config.split_by.push(col);
|
||||
let update = ViewConfigUpdate {
|
||||
split_by: Some(view_config.split_by),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::Filter, InPlaceColumn::Column(column)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
let op = ctx.props().default_op(column.as_str()).unwrap_or_default();
|
||||
view_config.filter.push(Filter::new(
|
||||
&column,
|
||||
&op,
|
||||
FilterTerm::Scalar(Scalar::Null),
|
||||
));
|
||||
|
||||
let update = ViewConfigUpdate {
|
||||
filter: Some(view_config.filter),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::Sort, InPlaceColumn::Column(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
view_config.sort.push(Sort(col, SortDir::Asc));
|
||||
let update = ViewConfigUpdate {
|
||||
sort: Some(view_config.sort),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::GroupBy, InPlaceColumn::Expression(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
view_config.group_by.push(col.name.as_ref().to_owned());
|
||||
view_config.expressions.insert(&col);
|
||||
let update = ViewConfigUpdate {
|
||||
group_by: Some(view_config.group_by),
|
||||
expressions: Some(view_config.expressions),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::SplitBy, InPlaceColumn::Expression(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
view_config.split_by.push(col.name.as_ref().to_owned());
|
||||
view_config.expressions.insert(&col);
|
||||
let update = ViewConfigUpdate {
|
||||
split_by: Some(view_config.split_by),
|
||||
expressions: Some(view_config.expressions),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::Filter, InPlaceColumn::Expression(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
let column = col.name.as_ref();
|
||||
view_config.filter.push(Filter::new(
|
||||
column,
|
||||
&ctx.props()
|
||||
.default_op(col.name.as_ref())
|
||||
.unwrap_or_default(),
|
||||
FilterTerm::Scalar(Scalar::Null),
|
||||
));
|
||||
|
||||
view_config.expressions.insert(&col);
|
||||
let update = ViewConfigUpdate {
|
||||
filter: Some(view_config.filter),
|
||||
expressions: Some(view_config.expressions),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::Sort, InPlaceColumn::Expression(col)) => {
|
||||
let mut view_config = (*ctx.props().view_config).clone();
|
||||
view_config
|
||||
.sort
|
||||
.push(Sort(col.name.as_ref().to_owned(), SortDir::Asc));
|
||||
view_config.expressions.insert(&col);
|
||||
let update = ViewConfigUpdate {
|
||||
sort: Some(view_config.sort),
|
||||
expressions: Some(view_config.expressions),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
{
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
ConfigSelectorMsg::New(DragTarget::Active, _) => false,
|
||||
}
|
||||
}
|
||||
|
||||
// /// Should not render on change, as this component only depends on service
|
||||
// /// state.
|
||||
// fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool
|
||||
// { false
|
||||
// }
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let ConfigSelectorProps {
|
||||
presentation,
|
||||
renderer,
|
||||
session,
|
||||
..
|
||||
} = ctx.props();
|
||||
let config = &ctx.props().view_config;
|
||||
let transpose = ctx.link().callback(|_| ConfigSelectorMsg::TransposePivots);
|
||||
let column_dropdown = self.column_dropdown.clone();
|
||||
let mut class = classes!();
|
||||
|
||||
if ctx.props().drag_column.is_some() {
|
||||
class.push("dragdrop-highlight");
|
||||
}
|
||||
|
||||
if config.group_rollup_mode == GroupRollupMode::Total {
|
||||
class.push("group-rollup-mode-total");
|
||||
}
|
||||
|
||||
let dragend = Callback::from({
|
||||
let presentation = presentation.clone();
|
||||
move |_event| presentation.notify_drag_end()
|
||||
});
|
||||
|
||||
let metadata = &ctx.props().metadata;
|
||||
let features = metadata.get_features().unwrap();
|
||||
let requirements = renderer.metadata();
|
||||
let on_group_rollup_mode = ctx
|
||||
.link()
|
||||
.callback(ConfigSelectorMsg::UpdateGroupRollupMode);
|
||||
|
||||
let rollup_features = metadata
|
||||
.get_features()
|
||||
.map(|x| x.get_group_rollup_modes())
|
||||
.unwrap();
|
||||
|
||||
let group_rollups = requirements.get_group_rollups(&rollup_features);
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div slot="top_panel" id="top_panel" {class} ondragend={dragend}>
|
||||
<div class="pivot_controls">
|
||||
if group_rollups.len() > 1 {
|
||||
<Select<GroupRollupMode>
|
||||
id="group_rollup_mode_selector"
|
||||
wrapper_class="group_rollup_wrapper"
|
||||
is_autosize=true
|
||||
values={Rc::new(
|
||||
group_rollups
|
||||
.iter()
|
||||
.map(|x| SelectItem::Option(*x))
|
||||
.collect(),
|
||||
)}
|
||||
selected={config.group_rollup_mode}
|
||||
on_select={on_group_rollup_mode}
|
||||
/>
|
||||
}
|
||||
if !config.group_by.is_empty() && config.split_by.is_empty() {
|
||||
<span
|
||||
id="transpose_button"
|
||||
class="rrow centered"
|
||||
title="Transpose Pivots"
|
||||
onmousedown={transpose.clone()}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
if features.group_by {
|
||||
<GroupBySelector
|
||||
name="group_by"
|
||||
disabled={config.group_rollup_mode == GroupRollupMode::Total}
|
||||
parent={ctx.link().clone()}
|
||||
column_dropdown={column_dropdown.clone()}
|
||||
exclude={config.group_by.iter().cloned().collect::<HashSet<_>>()}
|
||||
is_dragover={ctx.props().presentation.is_dragover(DragTarget::GroupBy)}
|
||||
{presentation}
|
||||
>
|
||||
{ for config.group_by.iter().map(|group_by| {
|
||||
html_nested! {
|
||||
<PivotColumn
|
||||
action={DragTarget::GroupBy}
|
||||
column={group_by.clone()}
|
||||
metadata={metadata.clone()}
|
||||
{presentation}
|
||||
opt_session={session}
|
||||
>
|
||||
</PivotColumn>
|
||||
}
|
||||
}) }
|
||||
</GroupBySelector>
|
||||
}
|
||||
if features.split_by {
|
||||
if !config.split_by.is_empty() {
|
||||
<div class="pivot_controls">
|
||||
<span
|
||||
id="transpose_button"
|
||||
class="rrow centered"
|
||||
title="Transpose Pivots"
|
||||
onmousedown={transpose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<SplitBySelector
|
||||
name="split_by"
|
||||
parent={ctx.link().clone()}
|
||||
column_dropdown={column_dropdown.clone()}
|
||||
exclude={config.split_by.iter().cloned().collect::<HashSet<_>>()}
|
||||
is_dragover={presentation.is_dragover(DragTarget::SplitBy)}
|
||||
{presentation}
|
||||
>
|
||||
{ for config.split_by.iter().map(|split_by| {
|
||||
html_nested! {
|
||||
<PivotColumn
|
||||
action={ DragTarget::SplitBy }
|
||||
column={ split_by.clone() }
|
||||
metadata={metadata.clone()}
|
||||
{presentation}
|
||||
opt_session={session}>
|
||||
</PivotColumn>
|
||||
}
|
||||
}) }
|
||||
</SplitBySelector>
|
||||
}
|
||||
if features.sort {
|
||||
<SortSelector
|
||||
name="sort"
|
||||
allow_duplicates=true
|
||||
parent={ctx.link().clone()}
|
||||
column_dropdown={column_dropdown.clone()}
|
||||
exclude={config.sort.iter().map(|x| x.0.clone()).collect::<HashSet<_>>()}
|
||||
is_dragover={presentation.is_dragover(DragTarget::Sort).map(|(index, name)| {
|
||||
(index, Sort(name, SortDir::Asc))
|
||||
})}
|
||||
{presentation}
|
||||
>
|
||||
{ for config.sort.iter().enumerate().map(|(idx, sort)| {
|
||||
html_nested! {
|
||||
<SortColumn
|
||||
idx={ idx }
|
||||
sort={ sort.clone() }
|
||||
view_config={config.clone()}
|
||||
metadata={metadata.clone()}
|
||||
{presentation}
|
||||
{renderer}
|
||||
{session}>
|
||||
</SortColumn>
|
||||
}
|
||||
}) }
|
||||
</SortSelector>
|
||||
}
|
||||
if !features.filter_ops.is_empty() {
|
||||
<FilterSelector
|
||||
name="filter"
|
||||
allow_duplicates=true
|
||||
parent={ctx.link().clone()}
|
||||
{column_dropdown}
|
||||
exclude={config.filter.iter().map(|x| x.column().to_string()).collect::<HashSet<_>>()}
|
||||
is_dragover={presentation.is_dragover(DragTarget::Filter).map(|(index, name)| {
|
||||
(index, Filter::new(&name, "", FilterTerm::Scalar(Scalar::Null)))
|
||||
})}
|
||||
{presentation}
|
||||
>
|
||||
{ for config.filter.iter().enumerate().map(|(idx, filter)| {
|
||||
let filter_keydown = ctx.link()
|
||||
.callback(move |txt| ConfigSelectorMsg::SetFilterValue(idx, txt));
|
||||
|
||||
html_nested! {
|
||||
<FilterColumn
|
||||
idx={ idx }
|
||||
filter_dropdown={ &self.filter_dropdown }
|
||||
filter={ filter.clone() }
|
||||
on_keydown={ filter_keydown }
|
||||
view_config={config.clone()}
|
||||
metadata={metadata.clone()}
|
||||
{presentation}
|
||||
{renderer}
|
||||
{session}>
|
||||
</FilterColumn>
|
||||
}
|
||||
}) }
|
||||
</FilterSelector>
|
||||
}
|
||||
</div>
|
||||
<ColumnDropDownPortal
|
||||
element={self.column_dropdown.clone()}
|
||||
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
|
||||
/>
|
||||
<FilterDropDownPortal
|
||||
element={self.filter_dropdown.clone()}
|
||||
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigSelectorProps {
|
||||
fn default_op(&self, column: &str) -> Option<String> {
|
||||
let features = self.metadata.get_features()?;
|
||||
let col_type = self.metadata.get_column_table_type(column)?;
|
||||
let first = features.default_op(col_type)?;
|
||||
Some(first.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupByContext {}
|
||||
struct SplitByContext {}
|
||||
struct SortDragContext {}
|
||||
struct FilterDragContext {}
|
||||
|
||||
impl DragContext<ConfigSelectorMsg> for GroupByContext {
|
||||
fn dragenter(index: usize) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::DragOver(index, DragTarget::GroupBy)
|
||||
}
|
||||
|
||||
fn close(index: usize) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::Close(index, DragTarget::GroupBy)
|
||||
}
|
||||
|
||||
fn dragleave() -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::DragLeave(DragTarget::GroupBy)
|
||||
}
|
||||
|
||||
fn create(col: InPlaceColumn) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::New(DragTarget::GroupBy, col)
|
||||
}
|
||||
|
||||
fn is_self_move(target: DragTarget) -> bool {
|
||||
target == DragTarget::GroupBy
|
||||
}
|
||||
}
|
||||
|
||||
impl DragContext<ConfigSelectorMsg> for SplitByContext {
|
||||
fn dragenter(index: usize) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::DragOver(index, DragTarget::SplitBy)
|
||||
}
|
||||
|
||||
fn close(index: usize) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::Close(index, DragTarget::SplitBy)
|
||||
}
|
||||
|
||||
fn dragleave() -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::DragLeave(DragTarget::SplitBy)
|
||||
}
|
||||
|
||||
fn create(col: InPlaceColumn) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::New(DragTarget::SplitBy, col)
|
||||
}
|
||||
|
||||
fn is_self_move(target: DragTarget) -> bool {
|
||||
target == DragTarget::SplitBy
|
||||
}
|
||||
}
|
||||
|
||||
impl DragContext<ConfigSelectorMsg> for SortDragContext {
|
||||
fn dragenter(index: usize) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::DragOver(index, DragTarget::Sort)
|
||||
}
|
||||
|
||||
fn close(index: usize) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::Close(index, DragTarget::Sort)
|
||||
}
|
||||
|
||||
fn dragleave() -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::DragLeave(DragTarget::Sort)
|
||||
}
|
||||
|
||||
fn create(col: InPlaceColumn) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::New(DragTarget::Sort, col)
|
||||
}
|
||||
|
||||
fn is_self_move(target: DragTarget) -> bool {
|
||||
target == DragTarget::Sort
|
||||
}
|
||||
}
|
||||
|
||||
impl DragContext<ConfigSelectorMsg> for FilterDragContext {
|
||||
fn dragenter(index: usize) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::DragOver(index, DragTarget::Filter)
|
||||
}
|
||||
|
||||
fn close(index: usize) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::Close(index, DragTarget::Filter)
|
||||
}
|
||||
|
||||
fn dragleave() -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::DragLeave(DragTarget::Filter)
|
||||
}
|
||||
|
||||
fn create(col: InPlaceColumn) -> ConfigSelectorMsg {
|
||||
ConfigSelectorMsg::New(DragTarget::Filter, col)
|
||||
}
|
||||
|
||||
fn is_self_move(target: DragTarget) -> bool {
|
||||
target == DragTarget::Filter
|
||||
}
|
||||
}
|
||||
|
||||
type GroupBySelector = DragDropList<ConfigSelector, PivotColumn, GroupByContext>;
|
||||
type SplitBySelector = DragDropList<ConfigSelector, PivotColumn, SplitByContext>;
|
||||
type SortSelector = DragDropList<ConfigSelector, SortColumn, SortDragContext>;
|
||||
type FilterSelector = DragDropList<ConfigSelector, FilterColumn, FilterDragContext>;
|
||||
@@ -0,0 +1,136 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use perspective_client::config::Expression;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::column_dropdown::ColumnDropDownElement;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct EmptyColumnProps {
|
||||
/// The autocomplete dropdown element.
|
||||
pub column_dropdown: ColumnDropDownElement,
|
||||
|
||||
/// Values to exclude from autocomplete
|
||||
pub exclude: HashSet<String>,
|
||||
|
||||
/// Fires when a value is selected.
|
||||
pub on_select: Callback<InPlaceColumn>,
|
||||
}
|
||||
|
||||
impl PartialEq for EmptyColumnProps {
|
||||
fn eq(&self, _other: &Self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum InPlaceColumn {
|
||||
Column(String),
|
||||
Expression(Expression<'static>),
|
||||
}
|
||||
|
||||
pub enum EmptyColumnMsg {
|
||||
KeyDown(u32),
|
||||
Blur,
|
||||
Input,
|
||||
}
|
||||
|
||||
use EmptyColumnMsg::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EmptyColumn {
|
||||
input_ref: NodeRef,
|
||||
}
|
||||
|
||||
impl Component for EmptyColumn {
|
||||
type Message = EmptyColumnMsg;
|
||||
type Properties = EmptyColumnProps;
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let onblur = ctx.link().callback(|_| Blur);
|
||||
let oninput = ctx.link().callback(|_| Input);
|
||||
let onkeydown = ctx
|
||||
.link()
|
||||
.callback(|event: KeyboardEvent| KeyDown(event.key_code()));
|
||||
|
||||
html! {
|
||||
<div class="pivot-column column-empty">
|
||||
<input
|
||||
spellcheck="false"
|
||||
ref={self.input_ref.clone()}
|
||||
{onblur}
|
||||
{onkeydown}
|
||||
{oninput}
|
||||
class="column-empty-input"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, _ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
|
||||
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
|
||||
elem.blur().unwrap();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn destroy(&mut self, ctx: &Context<Self>) {
|
||||
ctx.props().column_dropdown.hide().unwrap();
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Blur => {
|
||||
ctx.props().column_dropdown.hide().unwrap();
|
||||
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
|
||||
elem.set_value("");
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
KeyDown(40) => {
|
||||
ctx.props().column_dropdown.item_down();
|
||||
false
|
||||
},
|
||||
KeyDown(38) => {
|
||||
ctx.props().column_dropdown.item_up();
|
||||
false
|
||||
},
|
||||
KeyDown(13) => {
|
||||
ctx.props().column_dropdown.item_select();
|
||||
ctx.props().column_dropdown.hide().unwrap();
|
||||
false
|
||||
},
|
||||
KeyDown(_) => false,
|
||||
Input => {
|
||||
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
|
||||
ctx.props().column_dropdown.autocomplete(
|
||||
elem,
|
||||
ctx.props().exclude.clone(),
|
||||
ctx.props().on_select.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::ColumnLocator;
|
||||
|
||||
#[derive(PartialEq, Clone, Properties)]
|
||||
pub struct ExprEditButtonProps {
|
||||
/// Column name.
|
||||
pub name: String,
|
||||
|
||||
/// Is this an expression column?
|
||||
pub is_expression: bool,
|
||||
|
||||
/// Fires when the config/expresison button is clicked.
|
||||
pub on_open_expr_panel: Callback<ColumnLocator>,
|
||||
|
||||
/// Is the expression/config panel open?
|
||||
pub is_editing: bool,
|
||||
|
||||
/// Is the expression/config panel enabled? If not, show an invisible
|
||||
/// square in the same dimensions (so the layout does not jump around).
|
||||
pub is_disabled: bool,
|
||||
}
|
||||
|
||||
/// A button that goes into a column-list for a custom expression
|
||||
/// when pressed, it opens up the expression editor side-panel.
|
||||
#[function_component]
|
||||
pub fn ExprEditButton(p: &ExprEditButtonProps) -> Html {
|
||||
let onmousedown = yew::use_callback(p.clone(), |_, p| {
|
||||
let name = if p.is_expression {
|
||||
ColumnLocator::Expression(p.name.clone())
|
||||
} else {
|
||||
ColumnLocator::Table(p.name.clone())
|
||||
};
|
||||
p.on_open_expr_panel.emit(name)
|
||||
});
|
||||
|
||||
let class = if p.is_disabled {
|
||||
"expression-edit-button disabled"
|
||||
} else if p.is_editing {
|
||||
"expression-edit-button is-editing"
|
||||
} else {
|
||||
"expression-edit-button"
|
||||
};
|
||||
|
||||
html! { <span {onmousedown} {class}><span class="icon" /></span> }
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use chrono::{Datelike, NaiveDate, TimeZone, Utc};
|
||||
use perspective_client::config::*;
|
||||
use perspective_js::utils::ApiFuture;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::containers::dragdrop_list::*;
|
||||
use crate::components::containers::select::*;
|
||||
use crate::components::filter_dropdown::FilterDropDownElement;
|
||||
use crate::components::type_icon::TypeIcon;
|
||||
use crate::presentation::Presentation;
|
||||
use crate::renderer::*;
|
||||
use crate::session::*;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Clone, Properties)]
|
||||
pub struct FilterColumnProps {
|
||||
pub filter: Filter,
|
||||
pub idx: usize,
|
||||
pub filter_dropdown: FilterDropDownElement,
|
||||
pub on_keydown: Callback<String>,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
/// Current view config threaded as a value prop.
|
||||
pub view_config: PtrEqRc<ViewConfig>,
|
||||
|
||||
// State
|
||||
pub session: Session,
|
||||
pub renderer: Renderer,
|
||||
pub presentation: Presentation,
|
||||
}
|
||||
|
||||
impl PartialEq for FilterColumnProps {
|
||||
fn eq(&self, rhs: &Self) -> bool {
|
||||
self.idx == rhs.idx
|
||||
&& self.filter == rhs.filter
|
||||
&& self.on_keydown == rhs.on_keydown
|
||||
&& self.metadata == rhs.metadata
|
||||
&& self.view_config == rhs.view_config
|
||||
}
|
||||
}
|
||||
|
||||
impl DragDropListItemProps for FilterColumnProps {
|
||||
type Item = Filter;
|
||||
|
||||
fn get_item(&self) -> Filter {
|
||||
self.filter.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FilterColumnMsg {
|
||||
FilterInput((usize, String), String),
|
||||
Close,
|
||||
FilterOpSelect(String),
|
||||
FilterKeyDown(u32),
|
||||
}
|
||||
|
||||
/// A control for a single filter condition.
|
||||
pub struct FilterColumn {
|
||||
input: String,
|
||||
input_ref: NodeRef,
|
||||
filter_ops: Rc<Vec<SelectItem<String>>>,
|
||||
}
|
||||
|
||||
impl Component for FilterColumn {
|
||||
type Message = FilterColumnMsg;
|
||||
type Properties = FilterColumnProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let input_ref = NodeRef::default();
|
||||
let mut this = Self {
|
||||
input: "".to_string(),
|
||||
input_ref,
|
||||
filter_ops: Rc::default(),
|
||||
};
|
||||
|
||||
let col_type = ctx.props().get_current_filter_type();
|
||||
this.input = ctx
|
||||
.props()
|
||||
.get_filter_input()
|
||||
.unwrap_or_else(|| "".to_owned());
|
||||
|
||||
this.filter_ops = Rc::new(
|
||||
try {
|
||||
get_filter_ops(&ctx.props().metadata, col_type?)?
|
||||
.into_iter()
|
||||
.map(SelectItem::Option)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
if col_type == Some(ColumnType::Boolean) {
|
||||
ctx.props().update_filter_input(this.input.clone());
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: FilterColumnMsg) -> bool {
|
||||
match msg {
|
||||
FilterColumnMsg::FilterInput(column, input) => {
|
||||
let target = self.input_ref.cast::<HtmlInputElement>().unwrap();
|
||||
let input = if ctx.props().get_current_filter_type() == Some(ColumnType::Boolean) {
|
||||
if target.checked() {
|
||||
"true".to_owned()
|
||||
} else {
|
||||
"false".to_owned()
|
||||
}
|
||||
} else {
|
||||
input
|
||||
};
|
||||
|
||||
// TODO This belongs in the Features API.
|
||||
if ctx.props().is_suggestable() {
|
||||
ctx.props().filter_dropdown.autocomplete(
|
||||
column,
|
||||
if ctx.props().filter.op() == "in" || ctx.props().filter.op() == "not in" {
|
||||
input.split(',').next_back().unwrap().to_owned()
|
||||
} else {
|
||||
input.clone()
|
||||
},
|
||||
HashSet::new(),
|
||||
target.unchecked_into(),
|
||||
ctx.props().on_keydown.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
ctx.props().update_filter_input(input);
|
||||
false
|
||||
},
|
||||
FilterColumnMsg::FilterKeyDown(40) => {
|
||||
if ctx.props().is_suggestable() {
|
||||
ctx.props().filter_dropdown.item_down();
|
||||
ctx.props().filter_dropdown.item_select();
|
||||
}
|
||||
false
|
||||
},
|
||||
FilterColumnMsg::FilterKeyDown(38) => {
|
||||
if ctx.props().is_suggestable() {
|
||||
ctx.props().filter_dropdown.item_up();
|
||||
ctx.props().filter_dropdown.item_select();
|
||||
}
|
||||
false
|
||||
},
|
||||
FilterColumnMsg::Close => {
|
||||
ctx.props().filter_dropdown.hide().unwrap();
|
||||
false
|
||||
},
|
||||
FilterColumnMsg::FilterKeyDown(13) => {
|
||||
if ctx.props().is_suggestable() {
|
||||
ctx.props().filter_dropdown.item_select();
|
||||
ctx.props().filter_dropdown.hide().unwrap();
|
||||
}
|
||||
false
|
||||
},
|
||||
FilterColumnMsg::FilterKeyDown(_) => false,
|
||||
FilterColumnMsg::FilterOpSelect(op) => {
|
||||
ctx.props().update_filter_op(ctx.props().idx, op);
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, old: &Self::Properties) -> bool {
|
||||
let col_type = ctx.props().get_current_filter_type();
|
||||
let old_col_type = ctx.props().get_filter_type(&old.filter);
|
||||
let mut changed = false;
|
||||
if col_type != old_col_type {
|
||||
changed = true;
|
||||
self.filter_ops = Rc::new(
|
||||
try {
|
||||
get_filter_ops(&ctx.props().metadata, col_type?)?
|
||||
.into_iter()
|
||||
.map(SelectItem::Option)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
};
|
||||
|
||||
if let Some(input) = ctx.props().get_filter_input() {
|
||||
self.input = input;
|
||||
changed = true
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let idx = ctx.props().idx;
|
||||
let filter = ctx.props().filter.clone();
|
||||
let column = filter.column().to_owned();
|
||||
let col_type = ctx.props().metadata.get_column_table_type(&column);
|
||||
let select = ctx.link().callback(FilterColumnMsg::FilterOpSelect);
|
||||
let noderef = &self.input_ref;
|
||||
let input = ctx.link().callback({
|
||||
let column = column.clone();
|
||||
move |input: InputEvent| {
|
||||
FilterColumnMsg::FilterInput(
|
||||
(idx, column.clone()),
|
||||
input
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value(),
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
let focus = ctx.link().callback({
|
||||
let input = self.input.clone();
|
||||
move |_: FocusEvent| FilterColumnMsg::FilterInput((idx, column.clone()), input.clone())
|
||||
});
|
||||
|
||||
let blur = ctx.link().callback(|_| FilterColumnMsg::Close);
|
||||
let keydown = ctx
|
||||
.link()
|
||||
.callback(move |event: KeyboardEvent| FilterColumnMsg::FilterKeyDown(event.key_code()));
|
||||
|
||||
let dragstart = Callback::from({
|
||||
let event_name = ctx.props().filter.column().to_owned();
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
move |event: DragEvent| {
|
||||
presentation.set_drag_image(&event).unwrap();
|
||||
presentation
|
||||
.notify_drag_start(event_name.to_string(), DragEffect::Move(DragTarget::Filter))
|
||||
}
|
||||
});
|
||||
|
||||
let dragend = Callback::from({
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
move |_event| presentation.notify_drag_end()
|
||||
});
|
||||
|
||||
let type_class = match col_type {
|
||||
Some(ColumnType::Float) | Some(ColumnType::Integer) => "num-filter",
|
||||
Some(ColumnType::String) => "string-filter",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let input_elem = match col_type {
|
||||
Some(ColumnType::Integer) => html! {
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Value"
|
||||
class="num-filter"
|
||||
step="1"
|
||||
ref={noderef.clone()}
|
||||
onkeydown={keydown}
|
||||
value={self.input.clone()}
|
||||
oninput={input}
|
||||
/>
|
||||
},
|
||||
Some(ColumnType::Float) => html! {
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Value"
|
||||
class="num-filter"
|
||||
ref={noderef.clone()}
|
||||
onkeydown={keydown}
|
||||
value={self.input.clone()}
|
||||
oninput={input}
|
||||
/>
|
||||
},
|
||||
Some(ColumnType::String) => html! {
|
||||
<input
|
||||
type="search"
|
||||
size="4"
|
||||
placeholder="Value"
|
||||
class="string-filter"
|
||||
spellcheck="false"
|
||||
// TODO This is dirty and it may not work in the future.
|
||||
onInput="this.parentNode.dataset.value=this.value"
|
||||
ref={noderef.clone()}
|
||||
onkeydown={keydown}
|
||||
onfocus={focus}
|
||||
onblur={blur}
|
||||
value={self.input.clone()}
|
||||
oninput={input}
|
||||
/>
|
||||
},
|
||||
Some(ColumnType::Date) => html! {
|
||||
<input
|
||||
type="date"
|
||||
placeholder="Value"
|
||||
class="date-filter"
|
||||
ref={noderef.clone()}
|
||||
onkeydown={keydown}
|
||||
value={self.input.clone()}
|
||||
oninput={input}
|
||||
/>
|
||||
},
|
||||
Some(ColumnType::Datetime) => html! {
|
||||
<input
|
||||
type="datetime-local"
|
||||
placeholder="Value"
|
||||
class="datetime-filter"
|
||||
step="0.001"
|
||||
ref={noderef.clone()}
|
||||
onkeydown={keydown}
|
||||
value={self.input.clone()}
|
||||
oninput={input}
|
||||
/>
|
||||
},
|
||||
Some(ColumnType::Boolean) => {
|
||||
html! {
|
||||
<input
|
||||
type="checkbox"
|
||||
class="alternate"
|
||||
ref={noderef.clone()}
|
||||
checked={self.input == "true"}
|
||||
oninput={input}
|
||||
/>
|
||||
}
|
||||
},
|
||||
None => {
|
||||
html! {}
|
||||
},
|
||||
};
|
||||
|
||||
let final_col_type = col_type.unwrap_or(ColumnType::Integer);
|
||||
|
||||
html! {
|
||||
<div
|
||||
class="pivot-column-draggable"
|
||||
draggable="true"
|
||||
ondragstart={dragstart}
|
||||
ondragend={dragend}
|
||||
>
|
||||
<div class="pivot-column-border">
|
||||
<span class="drag-handle icon" />
|
||||
// <TypeIcon ty={ColumnType::String} />
|
||||
<TypeIcon ty={final_col_type} />
|
||||
<span class="column_name">{ filter.column().to_owned() }</span>
|
||||
<Select<String>
|
||||
class="filterop-selector"
|
||||
is_autosize=true
|
||||
values={self.filter_ops.clone()}
|
||||
selected={filter.op().to_string()}
|
||||
on_select={select}
|
||||
/>
|
||||
// TODO: Move this to the Features API.
|
||||
if filter.op() != "is not null" && filter.op() != "is null" {
|
||||
if col_type == Some(ColumnType::Boolean) {
|
||||
{ input_elem }
|
||||
} else {
|
||||
<label
|
||||
class={format!("input-sizer {}", type_class)}
|
||||
data-value={format!("{}", filter.term())}
|
||||
>
|
||||
{ input_elem }
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the allowed `FilterOp`s for this filter.
|
||||
fn get_filter_ops(metadata: &SessionMetadata, col_type: ColumnType) -> Option<Vec<String>> {
|
||||
let features = metadata.get_features()?;
|
||||
features
|
||||
.filter_ops
|
||||
.get(&(col_type as u32))
|
||||
.map(|x| x.options.clone())
|
||||
}
|
||||
|
||||
impl FilterColumnProps {
|
||||
/// Does this filter item get a "suggestions" auto-complete modal?
|
||||
fn is_suggestable(&self) -> bool {
|
||||
// TODO This needs to be moved to Features API. Or ... we just do this
|
||||
// all string column type filters, or otherwise "fix" this in the UI?
|
||||
(self.filter.op() == "=="
|
||||
|| self.filter.op() == "!="
|
||||
|| self.filter.op() == "in"
|
||||
|| self.filter.op() == "not in")
|
||||
&& self.get_filter_type(&self.filter) == Some(ColumnType::String)
|
||||
}
|
||||
|
||||
fn get_current_filter_type(&self) -> Option<ColumnType> {
|
||||
self.get_filter_type(&self.filter)
|
||||
}
|
||||
|
||||
/// Get this filter's type, e.g. the type of the column.
|
||||
fn get_filter_type(&self, filter: &Filter) -> Option<ColumnType> {
|
||||
self.metadata.get_column_table_type(filter.column())
|
||||
}
|
||||
|
||||
// Get the string value, suitable for the `value` field of a `FilterColumns`'s
|
||||
// `<input>`.
|
||||
fn get_filter_input(&self) -> Option<String> {
|
||||
let filter_type = self.get_current_filter_type()?;
|
||||
match (&filter_type, &self.filter.term()) {
|
||||
(ColumnType::Date, FilterTerm::Scalar(Scalar::Float(x))) => {
|
||||
if *x > 0_f64 {
|
||||
Some(
|
||||
Utc.timestamp_opt(*x as i64 / 1000, (*x as u32 % 1000) * 1000)
|
||||
.earliest()?
|
||||
.format("%Y-%m-%d")
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
(ColumnType::Datetime, FilterTerm::Scalar(Scalar::Float(x))) => {
|
||||
posix_to_utc_str(*x).ok()
|
||||
},
|
||||
(ColumnType::Boolean, FilterTerm::Scalar(Scalar::Bool(x))) => {
|
||||
Some((if *x { "true" } else { "false" }).to_owned())
|
||||
},
|
||||
(ColumnType::Boolean, _) => Some("true".to_owned()),
|
||||
(_, x) => Some(format!("{x}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the filter comparison operator.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `op` The new `FilterOp`.
|
||||
fn update_filter_op(&self, idx: usize, op: String) {
|
||||
let mut filter = self.view_config.filter.clone();
|
||||
let filter_column = &mut filter.get_mut(idx).expect("Filter on no column");
|
||||
*filter_column.op_mut() = op;
|
||||
let update = ViewConfigUpdate {
|
||||
filter: Some(filter),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
if self.session.update_view_config(update).is_ok() {
|
||||
let session = self.session.clone();
|
||||
let renderer = self.renderer.clone();
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the filter value from the string input read from the DOM.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `val` The new filter value.
|
||||
fn update_filter_input(&self, val: String) {
|
||||
let mut filters = self.view_config.filter.clone();
|
||||
let filter_column = &mut filters.get_mut(self.idx).expect("Filter on no column");
|
||||
|
||||
// TODO This belongs in the Features API.
|
||||
let filter_input = if filter_column.op() == "in" || filter_column.op() == "not in" {
|
||||
Some(FilterTerm::Array(
|
||||
val.split(',')
|
||||
.map(|x| Scalar::String(x.trim().to_owned()))
|
||||
.collect(),
|
||||
))
|
||||
} else {
|
||||
match self.get_current_filter_type() {
|
||||
Some(ColumnType::String) => Some(FilterTerm::Scalar(Scalar::String(val))),
|
||||
Some(ColumnType::Integer) => {
|
||||
if val.is_empty() {
|
||||
None
|
||||
} else if let Ok(num) = val.parse::<f64>() {
|
||||
Some(FilterTerm::Scalar(Scalar::Float(num.floor())))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
Some(ColumnType::Float) => {
|
||||
if val.is_empty() {
|
||||
None
|
||||
} else if let Ok(num) = val.parse::<f64>() {
|
||||
Some(FilterTerm::Scalar(Scalar::Float(num)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
Some(ColumnType::Date) => match NaiveDate::parse_from_str(&val, "%Y-%m-%d") {
|
||||
Ok(ref posix) => Some(FilterTerm::Scalar(Scalar::String(format!(
|
||||
"{:0>4}-{:0>2}-{:0>2}",
|
||||
posix.year(),
|
||||
posix.month(),
|
||||
posix.day(),
|
||||
)))),
|
||||
_ => None,
|
||||
},
|
||||
Some(ColumnType::Datetime) => match str_to_utc_posix(&val) {
|
||||
Ok(x) => Some(FilterTerm::Scalar(Scalar::Float(x))),
|
||||
_ => None,
|
||||
},
|
||||
Some(ColumnType::Boolean) => Some(FilterTerm::Scalar(match val.as_str() {
|
||||
"true" => Scalar::Bool(true),
|
||||
_ => Scalar::Bool(false),
|
||||
})),
|
||||
|
||||
// shouldn't be reachable ..
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(input) = filter_input
|
||||
&& &input != filter_column.term()
|
||||
{
|
||||
*filter_column.term_mut() = input;
|
||||
let update = ViewConfigUpdate {
|
||||
filter: Some(filters),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
if self.session.update_view_config(update).is_ok() {
|
||||
let session = self.session.clone();
|
||||
let renderer = self.renderer.clone();
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use itertools::Itertools;
|
||||
use perspective_client::config::*;
|
||||
use perspective_js::utils::ApiFuture;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::expr_edit_button::*;
|
||||
use crate::components::type_icon::TypeIcon;
|
||||
use crate::config::ColumnSelectMode;
|
||||
use crate::presentation::{ColumnLocator, Presentation};
|
||||
use crate::renderer::*;
|
||||
use crate::session::*;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Clone, Properties)]
|
||||
pub struct InactiveColumnProps {
|
||||
/// This column's index in its list.
|
||||
pub idx: usize,
|
||||
|
||||
/// Is this column visible?
|
||||
pub visible: bool,
|
||||
|
||||
/// Column name
|
||||
pub name: String,
|
||||
|
||||
/// Is the expression/config panel open for this column?
|
||||
pub is_editing: bool,
|
||||
|
||||
/// Whether this column is an expression column. Computed by the parent
|
||||
/// so that changes to session metadata trigger a re-render via prop diff.
|
||||
#[prop_or_default]
|
||||
pub is_expression: bool,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
/// View config snapshot — threaded from parent so we avoid
|
||||
/// `session.get_view_config()` calls.
|
||||
pub view_config: PtrEqRc<ViewConfig>,
|
||||
|
||||
/// `dragend` event`.
|
||||
pub ondragend: Callback<()>,
|
||||
|
||||
/// Fires when this column's select button is sclicked.
|
||||
pub onselect: Callback<()>,
|
||||
|
||||
/// Fires when this column's expression/config button is clicked.
|
||||
pub on_open_expr_panel: Callback<ColumnLocator>,
|
||||
|
||||
// State
|
||||
pub presentation: Presentation,
|
||||
pub session: Session,
|
||||
pub renderer: Renderer,
|
||||
}
|
||||
|
||||
impl PartialEq for InactiveColumnProps {
|
||||
fn eq(&self, rhs: &Self) -> bool {
|
||||
self.idx == rhs.idx
|
||||
&& self.visible == rhs.visible
|
||||
&& self.name == rhs.name
|
||||
&& self.is_editing == rhs.is_editing
|
||||
&& self.is_expression == rhs.is_expression
|
||||
&& self.metadata == rhs.metadata
|
||||
&& self.view_config == rhs.view_config
|
||||
}
|
||||
}
|
||||
|
||||
pub enum InactiveColumnMsg {
|
||||
ActivateColumn(bool),
|
||||
MouseEnter(bool),
|
||||
MouseLeave(bool),
|
||||
}
|
||||
|
||||
use InactiveColumnMsg::*;
|
||||
|
||||
pub struct InactiveColumn {
|
||||
add_expression_ref: NodeRef,
|
||||
mouseover: bool,
|
||||
}
|
||||
|
||||
impl Component for InactiveColumn {
|
||||
type Message = InactiveColumnMsg;
|
||||
type Properties = InactiveColumnProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
add_expression_ref: NodeRef::default(),
|
||||
mouseover: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: InactiveColumnMsg) -> bool {
|
||||
match msg {
|
||||
ActivateColumn(shift_key) => {
|
||||
ctx.props()
|
||||
.activate_column(ctx.props().name.to_owned(), shift_key);
|
||||
ctx.props().onselect.emit(());
|
||||
false
|
||||
},
|
||||
MouseEnter(is_render) => {
|
||||
self.mouseover = is_render;
|
||||
is_render
|
||||
},
|
||||
MouseLeave(is_render) => {
|
||||
self.mouseover = false;
|
||||
is_render
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let col_type = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.get_column_table_type(&ctx.props().name)
|
||||
.unwrap_or(ColumnType::String);
|
||||
|
||||
let add_column = ctx
|
||||
.link()
|
||||
.callback(|event: MouseEvent| InactiveColumnMsg::ActivateColumn(event.shift_key()));
|
||||
|
||||
let ondragend = ctx.props().ondragend.reform(|_| {});
|
||||
let ondragstart = ctx.link().callback({
|
||||
let event_name = ctx.props().name.to_owned();
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
move |event: DragEvent| {
|
||||
presentation.set_drag_image(&event).unwrap();
|
||||
presentation.notify_drag_start(event_name.to_string(), DragEffect::Copy);
|
||||
MouseLeave(true)
|
||||
}
|
||||
});
|
||||
|
||||
let onmouseout = ctx.link().callback(|_| MouseLeave(true));
|
||||
let onmouseover = ctx
|
||||
.link()
|
||||
.callback(|event: MouseEvent| MouseEnter(event.which() == 0));
|
||||
|
||||
let is_expression = ctx.props().is_expression;
|
||||
|
||||
let mut is_active_class = ctx.props().renderer.metadata().select_mode.css();
|
||||
is_active_class.push("shift-alt-icon");
|
||||
let mut class = classes!("column-selector-column");
|
||||
if !ctx.props().visible {
|
||||
class.push("column-selector-column-hidden");
|
||||
}
|
||||
|
||||
if self.mouseover {
|
||||
class.push("dragdrop-hover");
|
||||
}
|
||||
|
||||
html! {
|
||||
<div {class} {onmouseover} {onmouseout} data-index={ctx.props().idx.to_string()}>
|
||||
<span class={is_active_class} onmousedown={add_column} />
|
||||
<div
|
||||
ref={&self.add_expression_ref}
|
||||
class="column-selector-draggable column-selector-column-title"
|
||||
draggable="true"
|
||||
{ondragstart}
|
||||
{ondragend}
|
||||
>
|
||||
<div class="column-selector-column-border">
|
||||
<span class="drag-handle icon" />
|
||||
<TypeIcon ty={col_type} />
|
||||
<span class="column_name">{ ctx.props().name.clone() }</span>
|
||||
<span class="column-selector--spacer" />
|
||||
<ExprEditButton
|
||||
name={ctx.props().name.clone()}
|
||||
on_open_expr_panel={&ctx.props().on_open_expr_panel}
|
||||
{is_expression}
|
||||
is_disabled={!is_expression}
|
||||
is_editing={ctx.props().is_editing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InactiveColumnProps {
|
||||
/// Add a column to the active columns, which corresponds to the `columns`
|
||||
/// field of the `JsPerspectiveViewConfig`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `name` The name of the column to de-activate, which is a unique ID
|
||||
/// with respect to `columns`.
|
||||
/// - `shift` whether to toggle or select this column.
|
||||
pub fn activate_column(&self, name: String, shift: bool) {
|
||||
let mut columns = self.view_config.columns.clone();
|
||||
let max_cols = self.renderer.metadata().config_column_names.len();
|
||||
|
||||
// Don't treat `None` at the end of the column list as columns, we'll refill
|
||||
// these later
|
||||
if let Some(last_filled) = columns.iter().rposition(|x| !x.is_none()) {
|
||||
columns.truncate(last_filled + 1);
|
||||
|
||||
let mode = self.renderer.metadata().select_mode;
|
||||
if (mode == ColumnSelectMode::Select) ^ shift {
|
||||
columns.clear();
|
||||
} else {
|
||||
columns.retain(|x| x.as_ref() != Some(&name));
|
||||
}
|
||||
|
||||
columns.push(Some(name));
|
||||
}
|
||||
|
||||
// Do this outside the loop so errors dont just become noops
|
||||
self.apply_columns(
|
||||
columns
|
||||
.into_iter()
|
||||
.pad_using(max_cols, |_| None)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_columns(&self, columns: Vec<Option<String>>) {
|
||||
let config = ViewConfigUpdate {
|
||||
columns: Some(columns),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
if self.session.update_view_config(config).is_ok() {
|
||||
let session = self.session.clone();
|
||||
let renderer = self.renderer.clone();
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(InvalidColumn)]
|
||||
pub fn invalid_column() -> Html {
|
||||
html! {
|
||||
<div class="pivot-column column-empty column-invalid">
|
||||
<div class="column-invalid-input" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use perspective_client::config::ColumnType;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::containers::dragdrop_list::*;
|
||||
use crate::components::type_icon::TypeIcon;
|
||||
use crate::presentation::Presentation;
|
||||
use crate::session::*;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct PivotColumnProps {
|
||||
/// Column name.
|
||||
pub column: String,
|
||||
|
||||
#[prop_or_default]
|
||||
pub column_type: Option<ColumnType>,
|
||||
|
||||
/// The drag starte of this column, if applicable.
|
||||
pub action: DragTarget,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
#[prop_or_default]
|
||||
pub metadata: Option<SessionMetadataRc>,
|
||||
|
||||
// State
|
||||
#[prop_or_default]
|
||||
pub opt_session: Option<Session>,
|
||||
pub presentation: Presentation,
|
||||
}
|
||||
|
||||
impl PartialEq for PivotColumnProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.column == other.column
|
||||
&& self.action == other.action
|
||||
&& self.metadata == other.metadata
|
||||
}
|
||||
}
|
||||
|
||||
impl DragDropListItemProps for PivotColumnProps {
|
||||
type Item = String;
|
||||
|
||||
fn get_item(&self) -> String {
|
||||
self.column.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PivotColumn;
|
||||
|
||||
impl Component for PivotColumn {
|
||||
type Message = ();
|
||||
type Properties = PivotColumnProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let dragstart = Callback::from({
|
||||
let event_name = ctx.props().column.to_owned();
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
let action = ctx.props().action;
|
||||
move |event: DragEvent| {
|
||||
presentation.set_drag_image(&event).unwrap();
|
||||
presentation.notify_drag_start(event_name.to_string(), DragEffect::Move(action))
|
||||
}
|
||||
});
|
||||
|
||||
let dragend = Callback::from({
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
move |_event| presentation.notify_drag_end()
|
||||
});
|
||||
|
||||
let col_type = ctx.props().column_type.unwrap_or_else(|| {
|
||||
ctx.props()
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|x| x.get_column_table_type(&ctx.props().column))
|
||||
.unwrap_or(ColumnType::Integer)
|
||||
});
|
||||
|
||||
html! {
|
||||
<div
|
||||
class="pivot-column-draggable"
|
||||
draggable="true"
|
||||
ondragstart={dragstart}
|
||||
ondragend={dragend}
|
||||
>
|
||||
<div class="pivot-column-border">
|
||||
<span class="drag-handle icon" />
|
||||
<TypeIcon ty={col_type} />
|
||||
<span class="column_name">{ ctx.props().column.clone() }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use perspective_client::config::*;
|
||||
use perspective_js::utils::ApiFuture;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::containers::dragdrop_list::*;
|
||||
use crate::components::type_icon::TypeIcon;
|
||||
use crate::presentation::Presentation;
|
||||
use crate::renderer::*;
|
||||
use crate::session::*;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct SortColumnProps {
|
||||
pub sort: Sort,
|
||||
pub idx: usize,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
/// Current view config — threaded as a value prop.
|
||||
pub view_config: PtrEqRc<ViewConfig>,
|
||||
|
||||
// State
|
||||
pub session: Session,
|
||||
pub renderer: Renderer,
|
||||
pub presentation: Presentation,
|
||||
}
|
||||
|
||||
impl PartialEq for SortColumnProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.sort == other.sort
|
||||
&& self.idx == other.idx
|
||||
&& self.metadata == other.metadata
|
||||
&& self.view_config == other.view_config
|
||||
}
|
||||
}
|
||||
|
||||
impl DragDropListItemProps for SortColumnProps {
|
||||
type Item = Sort;
|
||||
|
||||
fn get_item(&self) -> Sort {
|
||||
self.sort.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SortColumnMsg {
|
||||
SortDirClick(bool),
|
||||
}
|
||||
|
||||
/// A `SortColumn` includes the column name and `SortDir` arrow, a clickable
|
||||
/// button which cycles through the available `SortDir` states.
|
||||
pub struct SortColumn {}
|
||||
|
||||
impl Component for SortColumn {
|
||||
type Message = SortColumnMsg;
|
||||
type Properties = SortColumnProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: SortColumnMsg) -> bool {
|
||||
match msg {
|
||||
SortColumnMsg::SortDirClick(shift_key) => {
|
||||
let is_split = ctx.props().view_config.split_by.is_empty();
|
||||
let mut sort = ctx.props().view_config.sort.clone();
|
||||
let sort_column = &mut sort.get_mut(ctx.props().idx).expect("Sort on no column");
|
||||
sort_column.1 = sort_column.1.cycle(!is_split, shift_key);
|
||||
let update = ViewConfigUpdate {
|
||||
sort: Some(sort),
|
||||
..ViewConfigUpdate::default()
|
||||
};
|
||||
|
||||
let session = ctx.props().session.clone();
|
||||
let renderer = ctx.props().renderer.clone();
|
||||
if session.update_view_config(update).is_ok() {
|
||||
ApiFuture::spawn(async move {
|
||||
renderer.apply_pending_plugin()?;
|
||||
renderer.draw(session.validate().await?.create_view()).await
|
||||
});
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let onclick = ctx
|
||||
.link()
|
||||
.callback(|event: MouseEvent| SortColumnMsg::SortDirClick(event.shift_key()));
|
||||
|
||||
let dragstart = Callback::from({
|
||||
let event_name = ctx.props().sort.0.to_owned();
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
move |event: DragEvent| {
|
||||
presentation.set_drag_image(&event).unwrap();
|
||||
presentation
|
||||
.notify_drag_start(event_name.to_string(), DragEffect::Move(DragTarget::Sort))
|
||||
}
|
||||
});
|
||||
|
||||
let dragend = Callback::from({
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
move |_event| presentation.notify_drag_end()
|
||||
});
|
||||
|
||||
let col_type = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.get_column_table_type(&ctx.props().sort.0.to_owned())
|
||||
.unwrap_or(ColumnType::Integer);
|
||||
|
||||
html! {
|
||||
<div
|
||||
class="pivot-column-draggable"
|
||||
draggable="true"
|
||||
ondragstart={dragstart}
|
||||
ondragend={dragend}
|
||||
>
|
||||
<div class="pivot-column-border">
|
||||
<span class="drag-handle icon" />
|
||||
<TypeIcon ty={col_type} />
|
||||
// <TypeIcon ty={ColumnType::String} />
|
||||
<span class="column_name">{ ctx.props().sort.0.to_owned() }</span>
|
||||
<span
|
||||
class={format!("sort-icon {}", ctx.props().sort.1)}
|
||||
onmousedown={onclick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
mod attributes_tab;
|
||||
|
||||
mod save_settings;
|
||||
pub(crate) mod style_tab;
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use derivative::Derivative;
|
||||
use itertools::Itertools;
|
||||
use perspective_client::config::{ColumnType, Expression, ViewConfig};
|
||||
use perspective_client::utils::PerspectiveResultExt;
|
||||
use yew::{Callback, Component, Html, Properties, html, props};
|
||||
|
||||
use self::attributes_tab::AttributesTabProps;
|
||||
use self::style_tab::StyleTabProps;
|
||||
use crate::components::column_settings_sidebar::attributes_tab::AttributesTab;
|
||||
use crate::components::column_settings_sidebar::save_settings::SaveSettingsProps;
|
||||
use crate::components::column_settings_sidebar::style_tab::StyleTab;
|
||||
use crate::components::containers::sidebar::Sidebar;
|
||||
use crate::components::containers::tab_list::TabList;
|
||||
use crate::components::editable_header::EditableHeaderProps;
|
||||
use crate::components::expression_editor::ExpressionEditorProps;
|
||||
use crate::components::type_icon::TypeIconType;
|
||||
use crate::presentation::{ColumnLocator, ColumnSettingsTab, Presentation};
|
||||
use crate::renderer::Renderer;
|
||||
use crate::session::{Session, SessionMetadataRc};
|
||||
use crate::tasks::{delete_expr, save_expr, update_expr};
|
||||
use crate::utils::PtrEqRc;
|
||||
|
||||
#[derive(Clone, Derivative, Properties)]
|
||||
#[derivative(Debug)]
|
||||
pub struct ColumnSettingsPanelProps {
|
||||
pub selected_column: ColumnLocator,
|
||||
pub selected_tab: Option<ColumnSettingsTab>,
|
||||
pub on_close: Callback<()>,
|
||||
pub width_override: Option<i32>,
|
||||
pub on_select_tab: Callback<ColumnSettingsTab>,
|
||||
|
||||
/// Active plugin name threaded as a value prop so that plugin changes
|
||||
/// trigger re-initialization via `changed()` rather than a PubSub
|
||||
/// `render_limits_changed` subscription.
|
||||
pub plugin_name: Option<String>,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
/// View config snapshot — threaded from `SessionProps`.
|
||||
pub view_config: PtrEqRc<ViewConfig>,
|
||||
|
||||
/// Per-column stats snapshot — threaded from `SessionProps`.
|
||||
pub column_stats: PtrEqRc<std::collections::HashMap<String, crate::session::ColumnStats>>,
|
||||
|
||||
/// Selected theme name, threaded for PortalModal consumers.
|
||||
pub selected_theme: Option<String>,
|
||||
|
||||
// State
|
||||
#[derivative(Debug = "ignore")]
|
||||
pub presentation: Presentation,
|
||||
|
||||
#[derivative(Debug = "ignore")]
|
||||
pub renderer: Renderer,
|
||||
|
||||
#[derivative(Debug = "ignore")]
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
impl PartialEq for ColumnSettingsPanelProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.selected_column == other.selected_column
|
||||
&& self.selected_tab == other.selected_tab
|
||||
&& self.plugin_name == other.plugin_name
|
||||
&& self.metadata == other.metadata
|
||||
&& self.view_config == other.view_config
|
||||
&& self.column_stats == other.column_stats
|
||||
&& self.selected_theme == other.selected_theme
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ColumnSettingsPanelMsg {
|
||||
SetExprValue(Rc<String>),
|
||||
SetExprValid(bool),
|
||||
SetHeaderValue(Option<String>),
|
||||
SetHeaderValid(bool),
|
||||
SetSelectedTab((usize, ColumnSettingsTab)),
|
||||
OnSaveAttributes(()),
|
||||
OnResetAttributes(()),
|
||||
OnDelete(()),
|
||||
}
|
||||
|
||||
#[derive(Derivative)]
|
||||
#[derivative(Debug)]
|
||||
pub struct ColumnSettingsPanel {
|
||||
column_name: String,
|
||||
expr_valid: bool,
|
||||
expr_value: Rc<String>,
|
||||
header_valid: bool,
|
||||
header_value: Option<String>,
|
||||
initial_expr_value: Rc<String>,
|
||||
initial_header_value: Option<String>,
|
||||
maybe_ty: Option<ColumnType>,
|
||||
on_input: Callback<Rc<String>>,
|
||||
on_save: Callback<()>,
|
||||
on_validate: Callback<bool>,
|
||||
reset_count: u8,
|
||||
reset_enabled: bool,
|
||||
save_count: u8,
|
||||
save_enabled: bool,
|
||||
tabs: Vec<ColumnSettingsTab>,
|
||||
}
|
||||
|
||||
impl Component for ColumnSettingsPanel {
|
||||
type Message = ColumnSettingsPanelMsg;
|
||||
type Properties = ColumnSettingsPanelProps;
|
||||
|
||||
fn create(ctx: &yew::prelude::Context<Self>) -> Self {
|
||||
let mut this = Self {
|
||||
initial_expr_value: Rc::default(),
|
||||
expr_value: Rc::default(),
|
||||
expr_valid: false,
|
||||
initial_header_value: None,
|
||||
header_value: None,
|
||||
header_valid: false,
|
||||
save_enabled: false,
|
||||
save_count: 0,
|
||||
reset_enabled: false,
|
||||
reset_count: 0,
|
||||
column_name: "".to_owned(),
|
||||
maybe_ty: None,
|
||||
tabs: vec![],
|
||||
on_input: Callback::default(),
|
||||
on_save: Callback::default(),
|
||||
on_validate: Callback::default(),
|
||||
};
|
||||
|
||||
this.initialize(ctx);
|
||||
this
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &yew::prelude::Context<Self>, old_props: &Self::Properties) -> bool {
|
||||
if ctx.props() != old_props {
|
||||
self.initialize(ctx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &yew::prelude::Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ColumnSettingsPanelMsg::SetExprValue(val) => {
|
||||
if self.expr_value != val {
|
||||
self.expr_value = val;
|
||||
self.reset_enabled = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
ColumnSettingsPanelMsg::SetExprValid(val) => {
|
||||
self.expr_valid = val;
|
||||
self.save_enabled_effect();
|
||||
true
|
||||
},
|
||||
ColumnSettingsPanelMsg::SetHeaderValue(val) => {
|
||||
if self.header_value != val {
|
||||
self.header_value = val;
|
||||
self.reset_enabled = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
ColumnSettingsPanelMsg::SetHeaderValid(val) => {
|
||||
self.header_valid = val;
|
||||
self.save_enabled_effect();
|
||||
true
|
||||
},
|
||||
ColumnSettingsPanelMsg::SetSelectedTab((_, val)) => {
|
||||
let rerender = ctx.props().selected_tab != Some(val);
|
||||
ctx.props().on_select_tab.emit(val);
|
||||
rerender
|
||||
},
|
||||
ColumnSettingsPanelMsg::OnResetAttributes(()) => {
|
||||
self.header_value.clone_from(&self.initial_header_value);
|
||||
self.expr_value.clone_from(&self.initial_expr_value);
|
||||
self.save_enabled = false;
|
||||
self.reset_enabled = false;
|
||||
self.reset_count += 1;
|
||||
true
|
||||
},
|
||||
ColumnSettingsPanelMsg::OnSaveAttributes(()) => {
|
||||
let new_expr = Expression::new(
|
||||
self.header_value.clone().map(|s| s.into()),
|
||||
(*(self.expr_value)).clone().into(),
|
||||
);
|
||||
|
||||
match &ctx.props().selected_column {
|
||||
ColumnLocator::Table(_) => {
|
||||
tracing::error!("Tried to save non-expression column!")
|
||||
},
|
||||
ColumnLocator::Expression(name) => update_expr(
|
||||
&ctx.props().session,
|
||||
&ctx.props().renderer,
|
||||
&ctx.props().presentation,
|
||||
name.clone(),
|
||||
new_expr,
|
||||
),
|
||||
ColumnLocator::NewExpression => {
|
||||
if let Err(err) = save_expr(
|
||||
&ctx.props().session,
|
||||
&ctx.props().renderer,
|
||||
&ctx.props().presentation,
|
||||
new_expr,
|
||||
) {
|
||||
tracing::warn!("{}", err);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
self.initial_expr_value.clone_from(&self.expr_value);
|
||||
self.initial_header_value.clone_from(&self.header_value);
|
||||
self.save_enabled = false;
|
||||
self.reset_enabled = false;
|
||||
self.save_count += 1;
|
||||
true
|
||||
},
|
||||
ColumnSettingsPanelMsg::OnDelete(()) => {
|
||||
if ctx.props().selected_column.is_saved_expr() {
|
||||
delete_expr(
|
||||
&ctx.props().session,
|
||||
&ctx.props().renderer,
|
||||
&self.column_name,
|
||||
)
|
||||
.unwrap_or_log();
|
||||
}
|
||||
|
||||
ctx.props().on_close.emit(());
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &yew::prelude::Context<Self>) -> Html {
|
||||
let header_props = props!(EditableHeaderProps {
|
||||
initial_value: self.initial_header_value.clone(),
|
||||
placeholder: self.expr_value.clone(),
|
||||
reset_count: self.reset_count,
|
||||
editable: ctx.props().selected_column.is_expr()
|
||||
&& matches!(
|
||||
ctx.props().selected_tab,
|
||||
Some(ColumnSettingsTab::Attributes)
|
||||
),
|
||||
update_on_input: true,
|
||||
icon_type: self
|
||||
.maybe_ty
|
||||
.map(|ty| ty.into())
|
||||
.or(Some(TypeIconType::Expr)),
|
||||
on_change: ctx.link().batch_callback(|(value, valid)| {
|
||||
vec![
|
||||
ColumnSettingsPanelMsg::SetHeaderValue(value),
|
||||
ColumnSettingsPanelMsg::SetHeaderValid(valid),
|
||||
]
|
||||
}),
|
||||
metadata: ctx.props().metadata.clone(),
|
||||
session: &ctx.props().session
|
||||
});
|
||||
|
||||
let expr_editor = props!(ExpressionEditorProps {
|
||||
on_input: self.on_input.clone(),
|
||||
on_save: self.on_save.clone(),
|
||||
on_validate: self.on_validate.clone(),
|
||||
alias: ctx.props().selected_column.name().cloned(),
|
||||
disabled: !ctx.props().selected_column.is_expr(),
|
||||
reset_count: self.reset_count,
|
||||
metadata: ctx.props().metadata.clone(),
|
||||
selected_theme: ctx.props().selected_theme.clone(),
|
||||
session: &ctx.props().session
|
||||
});
|
||||
|
||||
let disable_delete = ctx
|
||||
.props()
|
||||
.selected_column
|
||||
.name()
|
||||
.map(|name| {
|
||||
let config = &ctx.props().view_config;
|
||||
config.columns.iter().any(|maybe_col| {
|
||||
maybe_col
|
||||
.as_ref()
|
||||
.map(|col| col == name)
|
||||
.unwrap_or_default()
|
||||
}) || config.group_by.iter().any(|col| col == name)
|
||||
|| config.split_by.iter().any(|col| col == name)
|
||||
|| config.filter.iter().any(|col| col.column() == name)
|
||||
|| config.sort.iter().any(|col| &col.0 == name)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let save_section = SaveSettingsProps {
|
||||
save_enabled: self.save_enabled,
|
||||
reset_enabled: self.reset_enabled,
|
||||
is_save: ctx.props().selected_column.name().is_some(),
|
||||
on_reset: ctx
|
||||
.link()
|
||||
.callback(ColumnSettingsPanelMsg::OnResetAttributes),
|
||||
on_save: ctx
|
||||
.link()
|
||||
.callback(ColumnSettingsPanelMsg::OnSaveAttributes),
|
||||
on_delete: ctx.link().callback(ColumnSettingsPanelMsg::OnDelete),
|
||||
show_danger_zone: ctx.props().selected_column.is_saved_expr(),
|
||||
disable_delete,
|
||||
};
|
||||
|
||||
let attrs_tab = AttributesTabProps {
|
||||
expr_editor,
|
||||
save_section,
|
||||
};
|
||||
|
||||
let style_tab = StyleTabProps {
|
||||
ty: self.maybe_ty,
|
||||
column_name: self.column_name.clone(),
|
||||
group_by_depth: ctx.props().view_config.group_by.len() as u32,
|
||||
view_config: ctx.props().view_config.clone(),
|
||||
metadata: ctx.props().metadata.clone(),
|
||||
column_stats: ctx.props().column_stats.clone(),
|
||||
selected_theme: ctx.props().selected_theme.clone(),
|
||||
presentation: ctx.props().presentation.clone(),
|
||||
renderer: ctx.props().renderer.clone(),
|
||||
session: ctx.props().session.clone(),
|
||||
};
|
||||
|
||||
let tab_children = self.tabs.iter().map(|tab| match tab {
|
||||
ColumnSettingsTab::Attributes => html! { <AttributesTab ..attrs_tab.clone() /> },
|
||||
ColumnSettingsTab::Style => html! { <StyleTab ..style_tab.clone() /> },
|
||||
});
|
||||
|
||||
let selected_tab_idx = self
|
||||
.tabs
|
||||
.iter()
|
||||
.find_position(|tab| Some(**tab) == ctx.props().selected_tab)
|
||||
.map(|(idx, _val)| idx)
|
||||
.unwrap_or_default();
|
||||
|
||||
html! {
|
||||
<>
|
||||
<Sidebar
|
||||
on_close={ctx.props().on_close.clone()}
|
||||
id_prefix="column_settings"
|
||||
width_override={ctx.props().width_override}
|
||||
selected_tab={selected_tab_idx}
|
||||
{header_props}
|
||||
>
|
||||
<TabList<ColumnSettingsTab>
|
||||
tabs={self.tabs.clone()}
|
||||
on_tab_change={ctx.link().callback(ColumnSettingsPanelMsg::SetSelectedTab)}
|
||||
selected_tab={selected_tab_idx}
|
||||
>
|
||||
{ for tab_children }
|
||||
</TabList<ColumnSettingsTab>>
|
||||
</Sidebar>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ColumnSettingsPanel {
|
||||
fn save_enabled_effect(&mut self) {
|
||||
let changed = self.expr_value != self.initial_expr_value
|
||||
|| self.header_value != self.initial_header_value;
|
||||
let valid = self.expr_valid && self.header_valid;
|
||||
self.save_enabled = changed && valid;
|
||||
}
|
||||
|
||||
fn initialize(&mut self, ctx: &yew::prelude::Context<Self>) {
|
||||
let column_name = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.locator_name_or_default(&ctx.props().selected_column);
|
||||
|
||||
let initial_expr_value = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.get_expression_by_alias(&column_name)
|
||||
.unwrap_or_default();
|
||||
|
||||
let initial_expr_value = Rc::new(initial_expr_value);
|
||||
let initial_header_value =
|
||||
(*initial_expr_value != column_name).then_some(column_name.clone());
|
||||
|
||||
let maybe_ty = ctx
|
||||
.props()
|
||||
.metadata
|
||||
.locator_view_type(&ctx.props().selected_column);
|
||||
|
||||
let tabs = {
|
||||
let mut tabs = vec![];
|
||||
let is_new_expr = ctx.props().selected_column.is_new_expr();
|
||||
let show_styles = !is_new_expr
|
||||
&& ctx.props().renderer.can_render_column_styles()
|
||||
&& ctx.props().view_config.columns.contains(&Some(
|
||||
ctx.props()
|
||||
.selected_column
|
||||
.name()
|
||||
.map(|x| x.to_string())
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
|
||||
if !is_new_expr && show_styles {
|
||||
tabs.push(ColumnSettingsTab::Style);
|
||||
}
|
||||
|
||||
if ctx.props().selected_column.is_expr() {
|
||||
tabs.push(ColumnSettingsTab::Attributes);
|
||||
}
|
||||
|
||||
tabs
|
||||
};
|
||||
|
||||
let on_input = ctx.link().callback(ColumnSettingsPanelMsg::SetExprValue);
|
||||
let on_save = ctx
|
||||
.link()
|
||||
.callback(ColumnSettingsPanelMsg::OnSaveAttributes);
|
||||
|
||||
let on_validate = ctx.link().callback(ColumnSettingsPanelMsg::SetExprValid);
|
||||
*self = Self {
|
||||
column_name,
|
||||
expr_value: initial_expr_value.clone(),
|
||||
initial_expr_value,
|
||||
header_value: initial_header_value.clone(),
|
||||
initial_header_value,
|
||||
maybe_ty,
|
||||
tabs,
|
||||
header_valid: true,
|
||||
on_input,
|
||||
on_save,
|
||||
on_validate,
|
||||
..*self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::{Html, Properties, function_component, html};
|
||||
|
||||
use super::save_settings::{SaveSettings, SaveSettingsProps};
|
||||
use crate::components::expression_editor::{ExpressionEditor, ExpressionEditorProps};
|
||||
|
||||
#[derive(PartialEq, Properties, Clone)]
|
||||
pub struct AttributesTabProps {
|
||||
pub expr_editor: ExpressionEditorProps,
|
||||
pub save_section: SaveSettingsProps,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn AttributesTab(p: &AttributesTabProps) -> Html {
|
||||
html! {
|
||||
<div id="attributes-tab">
|
||||
<div class="tab-section" id="attributes-expr">
|
||||
<ExpressionEditor ..p.expr_editor.clone() />
|
||||
</div>
|
||||
<div class="tab-section"><SaveSettings ..p.save_section.clone() /></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::{Callback, Html, Properties, function_component, html};
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct SaveSettingsProps {
|
||||
pub save_enabled: bool,
|
||||
pub reset_enabled: bool,
|
||||
pub on_reset: Callback<()>,
|
||||
pub on_save: Callback<()>,
|
||||
pub on_delete: Callback<()>,
|
||||
pub show_danger_zone: bool,
|
||||
pub disable_delete: bool,
|
||||
pub is_save: bool,
|
||||
}
|
||||
|
||||
#[function_component(SaveSettings)]
|
||||
pub fn save_settings(props: &SaveSettingsProps) -> Html {
|
||||
let reset = props.on_reset.reform(|_| ());
|
||||
let save = props.on_save.reform(|_| ());
|
||||
let delete = props.on_delete.reform(|_| ());
|
||||
html! {
|
||||
<div id="save-settings-wrapper">
|
||||
if props.show_danger_zone {
|
||||
<div id="danger-zone">
|
||||
<button
|
||||
id="psp-expression-editor-button-delete"
|
||||
class="psp-expression-editor__button"
|
||||
onmousedown={delete}
|
||||
disabled={props.disable_delete}
|
||||
>
|
||||
{ "Delete Column" }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
id="save-settings"
|
||||
>
|
||||
if props.is_save {
|
||||
<button
|
||||
id="psp-expression-editor-button-reset"
|
||||
class="psp-expression-editor__button"
|
||||
onmousedown={reset}
|
||||
disabled={!props.reset_enabled}
|
||||
>
|
||||
{ "Reset" }
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
id="psp-expression-editor-button-save"
|
||||
class="psp-expression-editor__button"
|
||||
onmousedown={save}
|
||||
disabled={!props.save_enabled}
|
||||
>
|
||||
{ if props.is_save { "Save" } else { "Create" } }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
mod agg_depth_selector;
|
||||
pub(crate) mod primitive_field;
|
||||
mod symbol;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use perspective_client::config::ColumnType;
|
||||
use yew::{Html, Properties, function_component, html};
|
||||
|
||||
use self::agg_depth_selector::*;
|
||||
use self::primitive_field::{
|
||||
BoolField, ColorField, ColorRangeField, EnumField, NumberFieldPrimitive,
|
||||
};
|
||||
use crate::components::column_settings_sidebar::style_tab::symbol::SymbolStyle;
|
||||
use crate::components::datetime_column_style::DatetimeColumnStyle;
|
||||
use crate::components::number_series_style::NumberSeriesStyle;
|
||||
use crate::components::string_column_style::StringColumnStyle;
|
||||
use crate::components::style_controls::CustomNumberFormat;
|
||||
use crate::config::{
|
||||
ControlSpec, CustomNumberFormatConfig, DatetimeColumnStyleConfig, NumberSeriesStyleConfig,
|
||||
StringColumnStyleConfig,
|
||||
};
|
||||
use crate::presentation::Presentation;
|
||||
use crate::queries::{fetch_column_abs_max, get_column_config_schema};
|
||||
use crate::renderer::Renderer;
|
||||
use crate::session::Session;
|
||||
use crate::tasks::send_column_config;
|
||||
use crate::utils::PtrEqRc;
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct StyleTabProps {
|
||||
pub ty: Option<ColumnType>,
|
||||
pub column_name: String,
|
||||
pub group_by_depth: u32,
|
||||
|
||||
/// View config snapshot — threaded from parent.
|
||||
pub view_config: PtrEqRc<perspective_client::config::ViewConfig>,
|
||||
|
||||
/// Session metadata snapshot — threaded from parent.
|
||||
pub metadata: PtrEqRc<crate::session::SessionMetadata>,
|
||||
|
||||
/// Per-column stats snapshot — threaded from `SessionProps`.
|
||||
pub column_stats: PtrEqRc<HashMap<String, crate::session::ColumnStats>>,
|
||||
|
||||
/// Selected theme name, threaded for PortalModal consumers.
|
||||
pub selected_theme: Option<String>,
|
||||
|
||||
// State
|
||||
pub presentation: Presentation,
|
||||
pub renderer: Renderer,
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn StyleTab(props: &StyleTabProps) -> Html {
|
||||
// Bumped on every primitive field change so Yew re-renders the tab
|
||||
// and re-queries `column_config_schema` with the new value. Without
|
||||
// this, dynamic field gating (e.g. show `Color` only when
|
||||
// `string_color_mode != none`) wouldn't surface until the user
|
||||
// closed and reopened the sidebar.
|
||||
let revision = yew::use_state(|| 0u32);
|
||||
|
||||
// `abs_max` lives in `Session`'s shared cache, propagated as a
|
||||
// value-semantic snapshot through `props.column_stats`. The cache
|
||||
// is cleared on `view_config_changed`; on cache miss we spawn the
|
||||
// fetch and let `column_stats_changed` drive the re-render via
|
||||
// `SessionProps`.
|
||||
let abs_max = props
|
||||
.column_stats
|
||||
.get(&props.column_name)
|
||||
.and_then(|s| s.abs_max);
|
||||
|
||||
yew::use_effect_with((props.column_name.clone(), props.view_config.clone()), {
|
||||
let session = props.session.clone();
|
||||
let column_name = props.column_name.clone();
|
||||
move |_| {
|
||||
fetch_column_abs_max(&session, column_name);
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
let raw_config = props.renderer.get_columns_config(&props.column_name);
|
||||
let on_change = {
|
||||
let state = props.clone();
|
||||
let column_name = props.column_name.clone();
|
||||
let revision = revision.clone();
|
||||
yew::Callback::from(move |config: crate::config::ColumnConfigFieldUpdate| {
|
||||
send_column_config(&state.session, &state.renderer, &column_name, config);
|
||||
revision.set(*revision + 1);
|
||||
})
|
||||
};
|
||||
|
||||
fn deser_sub<T: serde::de::DeserializeOwned>(
|
||||
raw: &Option<serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<T> {
|
||||
raw.as_ref()
|
||||
.and_then(|m| serde_json::from_value::<T>(serde_json::Value::Object(m.clone())).ok())
|
||||
}
|
||||
|
||||
let components = get_column_config_schema(
|
||||
&props.renderer,
|
||||
&props.view_config,
|
||||
&props.metadata,
|
||||
&props.column_name,
|
||||
raw_config.as_ref(),
|
||||
abs_max,
|
||||
)
|
||||
.map(|schema| {
|
||||
schema
|
||||
.fields
|
||||
.into_iter()
|
||||
.filter_map(|spec| {
|
||||
let keys: Vec<String> = spec
|
||||
.serialized_keys()
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let component = match spec {
|
||||
ControlSpec::AggregateDepth => {
|
||||
let aggregate_depth = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("aggregate_depth"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
html! {
|
||||
<AggregateDepthSelector
|
||||
group_by_depth={props.group_by_depth}
|
||||
on_change={on_change.clone()}
|
||||
column_name={props.column_name.to_owned()}
|
||||
value={aggregate_depth}
|
||||
keys={keys.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::NumberSeriesStyle {
|
||||
default: default_config,
|
||||
} => {
|
||||
let config: Option<NumberSeriesStyleConfig> = deser_sub(&raw_config);
|
||||
html! {
|
||||
<NumberSeriesStyle
|
||||
{config}
|
||||
{default_config}
|
||||
on_change={on_change.clone()}
|
||||
keys={keys.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::DatetimeFormat => {
|
||||
let config: Option<DatetimeColumnStyleConfig> = deser_sub(&raw_config);
|
||||
let enable_time_config = props.ty.unwrap() == ColumnType::Datetime;
|
||||
html! {
|
||||
<DatetimeColumnStyle
|
||||
{enable_time_config}
|
||||
{config}
|
||||
on_change={on_change.clone()}
|
||||
keys={keys.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::StringFormat => {
|
||||
let config: Option<StringColumnStyleConfig> = deser_sub(&raw_config);
|
||||
html! {
|
||||
<StringColumnStyle
|
||||
{config}
|
||||
on_change={on_change.clone()}
|
||||
keys={keys.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::Symbols {
|
||||
default: default_config,
|
||||
} => {
|
||||
let restored_config: HashMap<String, String> = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("symbols"))
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
html! {
|
||||
<SymbolStyle
|
||||
{default_config}
|
||||
restored_config={Some(restored_config)}
|
||||
on_change={on_change.clone()}
|
||||
column_name={props.column_name.clone()}
|
||||
selected_theme={props.selected_theme.clone()}
|
||||
session={props.session.clone()}
|
||||
keys={keys.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::NumberFormat => {
|
||||
let restored_config: CustomNumberFormatConfig = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("number_format"))
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
html! {
|
||||
<CustomNumberFormat
|
||||
{restored_config}
|
||||
on_change={on_change.clone()}
|
||||
view_type={props.ty.unwrap()}
|
||||
column_name={props.column_name.clone()}
|
||||
keys={keys.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::Enum {
|
||||
key,
|
||||
variants,
|
||||
default,
|
||||
} => {
|
||||
let current = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&key))
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||
|
||||
html! {
|
||||
<EnumField
|
||||
field_key={key}
|
||||
{variants}
|
||||
{default}
|
||||
{current}
|
||||
on_change={on_change.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::Bool { key, default } => {
|
||||
let current = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&key))
|
||||
.and_then(|v| v.as_bool());
|
||||
html! {
|
||||
<BoolField
|
||||
field_key={key}
|
||||
{default}
|
||||
{current}
|
||||
on_change={on_change.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::Color { key, default } => {
|
||||
let current = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&key))
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||
html! {
|
||||
<ColorField
|
||||
field_key={key}
|
||||
{default}
|
||||
{current}
|
||||
on_change={on_change.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::ColorRange {
|
||||
key_pos,
|
||||
key_neg,
|
||||
default_pos,
|
||||
default_neg,
|
||||
is_gradient,
|
||||
} => {
|
||||
let current_pos = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&key_pos))
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||
let current_neg = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&key_neg))
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||
html! {
|
||||
<ColorRangeField
|
||||
field_key_pos={key_pos}
|
||||
field_key_neg={key_neg}
|
||||
{default_pos}
|
||||
{default_neg}
|
||||
{current_pos}
|
||||
{current_neg}
|
||||
{is_gradient}
|
||||
on_change={on_change.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ControlSpec::Number {
|
||||
key,
|
||||
default,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
include,
|
||||
} => {
|
||||
let current = raw_config
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&key))
|
||||
.and_then(|v| v.as_f64());
|
||||
html! {
|
||||
<NumberFieldPrimitive
|
||||
field_key={key}
|
||||
{default}
|
||||
{current}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{include}
|
||||
on_change={on_change.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
// String primitive has no caller yet — wire when a
|
||||
// plugin emits one.
|
||||
ControlSpec::String { .. } => {
|
||||
return None;
|
||||
},
|
||||
};
|
||||
|
||||
Some(html! { <fieldset class="style-control">{ component }</fieldset> })
|
||||
})
|
||||
.collect_vec()
|
||||
})
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::error!("{}", error);
|
||||
vec![]
|
||||
});
|
||||
|
||||
html! {
|
||||
<div id="style-tab">
|
||||
<div id="column-style-container" class="tab-section">{ components }</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use perspective_client::clone;
|
||||
use yew::{Callback, Html, Properties, function_component, html};
|
||||
|
||||
use crate::components::form::number_field::NumberField;
|
||||
use crate::config::ColumnConfigFieldUpdate;
|
||||
|
||||
// ░░░█▀█░█▀▄░█▀█░█▀█░█▀▀░█▀▄░▀█▀░▀█▀░█▀▀░█▀▀░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
||||
// ░░░█▀▀░█▀▄░█░█░█▀▀░█▀▀░█▀▄░░█░░░█░░█▀▀░▀▀█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
||||
// ░░░▀░░░▀░▀░▀▀▀░▀░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀▀░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct AggregateDepthSelectorProps {
|
||||
pub on_change: Callback<ColumnConfigFieldUpdate>,
|
||||
pub value: u32,
|
||||
pub group_by_depth: u32,
|
||||
pub column_name: String,
|
||||
#[prop_or_default]
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn AggregateDepthSelector(props: &AggregateDepthSelectorProps) -> Html {
|
||||
let state = yew::use_state_eq(|| 0);
|
||||
yew::use_effect_with((props.column_name.to_owned(), props.group_by_depth), {
|
||||
clone!(state, props.value);
|
||||
move |deps| state.set(std::cmp::min(deps.1, value))
|
||||
});
|
||||
|
||||
let on_change = yew::use_callback(
|
||||
(state.setter(), props.on_change.clone(), props.keys.clone()),
|
||||
|x: Option<f64>, deps| {
|
||||
let depth = x.unwrap_or_default() as u32;
|
||||
deps.0.set(depth);
|
||||
let mut value = serde_json::Map::new();
|
||||
if depth > 0 {
|
||||
value.insert("aggregate_depth".to_owned(), serde_json::json!(depth));
|
||||
}
|
||||
deps.1.emit(ColumnConfigFieldUpdate {
|
||||
keys: deps.2.clone(),
|
||||
value,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
html! {
|
||||
<NumberField
|
||||
label="aggregate-depth"
|
||||
{on_change}
|
||||
min=0.0
|
||||
max={props.group_by_depth as f64}
|
||||
default=0.0
|
||||
current_value={*state as f64}
|
||||
/>
|
||||
}
|
||||
}
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
//! Schema-driven generic widgets for the Style tab. Each widget renders a
|
||||
//! single primitive [`crate::config::ControlSpec`] variant and emits a
|
||||
//! [`crate::config::ColumnConfigFieldUpdate`] on change. Built on top of
|
||||
//! the existing form components ([`Select`], [`OptionalField`],
|
||||
//! [`ColorSelector`]) so that they visually match the rich Yew widgets in
|
||||
//! the same sidebar.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde_json::Value;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{HtmlInputElement, MouseEvent};
|
||||
use yew::{Callback, Html, Properties, classes, function_component, html, use_callback};
|
||||
|
||||
use crate::components::containers::select::{Select, SelectItem};
|
||||
use crate::components::form::color_range_selector::ColorRangeSelector;
|
||||
use crate::components::form::color_selector::ColorSelector;
|
||||
use crate::components::form::number_field::NumberField;
|
||||
use crate::components::form::optional_field::OptionalField;
|
||||
use crate::config::{ColumnConfigFieldUpdate, EnumVariant};
|
||||
|
||||
fn emit(on_change: &Callback<ColumnConfigFieldUpdate>, key: &str, value: Option<Value>) {
|
||||
let mut map = serde_json::Map::new();
|
||||
if let Some(v) = value {
|
||||
map.insert(key.to_owned(), v);
|
||||
}
|
||||
|
||||
on_change.emit(ColumnConfigFieldUpdate {
|
||||
keys: vec![key.to_owned()],
|
||||
value: map,
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_color_range(
|
||||
on_change: &Callback<ColumnConfigFieldUpdate>,
|
||||
key_pos: &str,
|
||||
key_neg: &str,
|
||||
default_pos: &str,
|
||||
default_neg: &str,
|
||||
new_pos: &str,
|
||||
new_neg: &str,
|
||||
) {
|
||||
let mut value = serde_json::Map::new();
|
||||
if new_pos != default_pos {
|
||||
value.insert(key_pos.to_owned(), Value::String(new_pos.to_owned()));
|
||||
}
|
||||
if new_neg != default_neg {
|
||||
value.insert(key_neg.to_owned(), Value::String(new_neg.to_owned()));
|
||||
}
|
||||
on_change.emit(ColumnConfigFieldUpdate {
|
||||
keys: vec![key_pos.to_owned(), key_neg.to_owned()],
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EnumFieldProps {
|
||||
pub field_key: String,
|
||||
pub variants: Vec<EnumVariant>,
|
||||
pub default: String,
|
||||
pub current: Option<String>,
|
||||
pub on_change: Callback<ColumnConfigFieldUpdate>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn EnumField(props: &EnumFieldProps) -> Html {
|
||||
let selected = props
|
||||
.current
|
||||
.clone()
|
||||
.unwrap_or_else(|| props.default.clone());
|
||||
|
||||
let checked = selected != props.default;
|
||||
let values: Rc<Vec<SelectItem<String>>> = Rc::new(
|
||||
props
|
||||
.variants
|
||||
.iter()
|
||||
.map(|v| SelectItem::Option(v.value.clone()))
|
||||
.collect_vec(),
|
||||
);
|
||||
|
||||
let on_select = use_callback(
|
||||
(
|
||||
props.field_key.clone(),
|
||||
props.default.clone(),
|
||||
props.on_change.clone(),
|
||||
),
|
||||
|value: String, (key, default, on_change)| {
|
||||
if value == *default {
|
||||
emit(on_change, key, None);
|
||||
} else {
|
||||
emit(on_change, key, Some(Value::String(value)));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let on_reset = use_callback(
|
||||
(props.field_key.clone(), props.on_change.clone()),
|
||||
|_: MouseEvent, (key, on_change)| emit(on_change, key, None),
|
||||
);
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<OptionalField label={props.field_key.clone()} on_check={on_reset} {checked}>
|
||||
<Select<String> {values} {selected} {on_select} />
|
||||
</OptionalField>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct BoolFieldProps {
|
||||
pub field_key: String,
|
||||
pub default: bool,
|
||||
pub current: Option<bool>,
|
||||
pub on_change: Callback<ColumnConfigFieldUpdate>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn BoolField(props: &BoolFieldProps) -> Html {
|
||||
let current = props.current.unwrap_or(props.default);
|
||||
let oninput = use_callback(
|
||||
(
|
||||
props.field_key.clone(),
|
||||
props.default,
|
||||
props.on_change.clone(),
|
||||
),
|
||||
|e: yew::events::InputEvent, (key, default, on_change)| {
|
||||
let target: HtmlInputElement = e.target().unwrap().unchecked_into();
|
||||
let next = target.checked();
|
||||
if next == *default {
|
||||
emit(on_change, key, None);
|
||||
} else {
|
||||
emit(on_change, key, Some(Value::Bool(next)));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let checked = current != props.default;
|
||||
let on_reset = use_callback(
|
||||
(props.field_key.clone(), props.on_change.clone()),
|
||||
|_: MouseEvent, (key, on_change)| emit(on_change, key, None),
|
||||
);
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<OptionalField label={props.field_key.clone()} on_check={on_reset} {checked}>
|
||||
<div class="bool-field-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="alternate"
|
||||
id={format!("{}-checkbox", props.field_key)}
|
||||
checked={current}
|
||||
{oninput}
|
||||
/>
|
||||
<label for={format!("{}-checkbox", props.field_key)} class="bool-field-desc">
|
||||
{ if current { "Enabled" } else { "Disabled" } }
|
||||
</label>
|
||||
</div>
|
||||
</OptionalField>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct NumberFieldPrimitiveProps {
|
||||
pub field_key: String,
|
||||
pub default: f64,
|
||||
pub current: Option<f64>,
|
||||
pub on_change: Callback<ColumnConfigFieldUpdate>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub include: Option<bool>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub min: Option<f64>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub max: Option<f64>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub step: Option<f64>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn NumberFieldPrimitive(props: &NumberFieldPrimitiveProps) -> Html {
|
||||
let on_change_inner = use_callback(
|
||||
(
|
||||
props.field_key.clone(),
|
||||
props.default,
|
||||
props.on_change.clone(),
|
||||
props.include,
|
||||
),
|
||||
|value: Option<f64>, (key, default, on_change, include)| match value {
|
||||
Some(v) if include.unwrap_or_default() || v != *default => emit(
|
||||
on_change,
|
||||
key,
|
||||
Some(
|
||||
serde_json::Number::from_f64(v)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null),
|
||||
),
|
||||
),
|
||||
None if include.unwrap_or_default() => emit(
|
||||
on_change,
|
||||
key,
|
||||
Some(
|
||||
serde_json::Number::from_f64(*default)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null),
|
||||
),
|
||||
),
|
||||
_ => emit(on_change, key, None),
|
||||
},
|
||||
);
|
||||
|
||||
html! {
|
||||
<NumberField
|
||||
label={props.field_key.clone()}
|
||||
current_value={props.current}
|
||||
default={props.default}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
on_change={on_change_inner}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ColorRangeFieldProps {
|
||||
pub field_key_pos: String,
|
||||
pub field_key_neg: String,
|
||||
pub default_pos: String,
|
||||
pub default_neg: String,
|
||||
pub current_pos: Option<String>,
|
||||
pub current_neg: Option<String>,
|
||||
pub is_gradient: bool,
|
||||
pub on_change: Callback<ColumnConfigFieldUpdate>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn ColorRangeField(props: &ColorRangeFieldProps) -> Html {
|
||||
let pos = props
|
||||
.current_pos
|
||||
.clone()
|
||||
.unwrap_or_else(|| props.default_pos.clone());
|
||||
let neg = props
|
||||
.current_neg
|
||||
.clone()
|
||||
.unwrap_or_else(|| props.default_neg.clone());
|
||||
let is_modified = (props.current_pos.is_some()
|
||||
&& props.current_pos.as_deref() != Some(props.default_pos.as_str()))
|
||||
|| (props.current_neg.is_some()
|
||||
&& props.current_neg.as_deref() != Some(props.default_neg.as_str()));
|
||||
|
||||
// Multi-key emit: write whichever side(s) differ from default,
|
||||
// clear the others. Mirrors the apply semantics of
|
||||
// `ColumnConfigFieldUpdate { keys, value }` with both keys owned.
|
||||
let on_pos_color = use_callback(
|
||||
(
|
||||
props.field_key_pos.clone(),
|
||||
props.field_key_neg.clone(),
|
||||
props.default_pos.clone(),
|
||||
props.default_neg.clone(),
|
||||
props.on_change.clone(),
|
||||
neg.clone(),
|
||||
),
|
||||
|new_pos: String, (key_pos, key_neg, default_pos, default_neg, on_change, neg)| {
|
||||
emit_color_range(
|
||||
on_change,
|
||||
key_pos,
|
||||
key_neg,
|
||||
default_pos,
|
||||
default_neg,
|
||||
&new_pos,
|
||||
neg,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
let on_neg_color = use_callback(
|
||||
(
|
||||
props.field_key_pos.clone(),
|
||||
props.field_key_neg.clone(),
|
||||
props.default_pos.clone(),
|
||||
props.default_neg.clone(),
|
||||
props.on_change.clone(),
|
||||
pos.clone(),
|
||||
),
|
||||
|new_neg: String, (key_pos, key_neg, default_pos, default_neg, on_change, pos)| {
|
||||
emit_color_range(
|
||||
on_change,
|
||||
key_pos,
|
||||
key_neg,
|
||||
default_pos,
|
||||
default_neg,
|
||||
pos,
|
||||
&new_neg,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
let on_reset = use_callback(
|
||||
(
|
||||
props.field_key_pos.clone(),
|
||||
props.field_key_neg.clone(),
|
||||
props.on_change.clone(),
|
||||
),
|
||||
|_: (), (key_pos, key_neg, on_change)| {
|
||||
on_change.emit(ColumnConfigFieldUpdate {
|
||||
keys: vec![key_pos.clone(), key_neg.clone()],
|
||||
value: serde_json::Map::new(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<ColorRangeSelector
|
||||
pos_class={classes!(props.field_key_pos.clone())}
|
||||
neg_class={classes!(props.field_key_neg.clone())}
|
||||
pos_color={pos}
|
||||
neg_color={neg}
|
||||
is_gradient={props.is_gradient}
|
||||
{on_pos_color}
|
||||
{on_neg_color}
|
||||
{on_reset}
|
||||
{is_modified}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ColorFieldProps {
|
||||
pub field_key: String,
|
||||
pub default: String,
|
||||
pub current: Option<String>,
|
||||
pub on_change: Callback<ColumnConfigFieldUpdate>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn ColorField(props: &ColorFieldProps) -> Html {
|
||||
let color = props
|
||||
.current
|
||||
.clone()
|
||||
.unwrap_or_else(|| props.default.clone());
|
||||
let is_modified =
|
||||
props.current.as_deref() != Some(props.default.as_str()) && props.current.is_some();
|
||||
|
||||
let on_color = use_callback(
|
||||
(
|
||||
props.field_key.clone(),
|
||||
props.default.clone(),
|
||||
props.on_change.clone(),
|
||||
),
|
||||
|value: String, (key, default, on_change)| {
|
||||
if value == *default {
|
||||
emit(on_change, key, None);
|
||||
} else {
|
||||
emit(on_change, key, Some(Value::String(value)));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let on_reset = use_callback(
|
||||
(props.field_key.clone(), props.on_change.clone()),
|
||||
|_: (), (key, on_change)| emit(on_change, key, None),
|
||||
);
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<ColorSelector
|
||||
{color}
|
||||
{on_color}
|
||||
{on_reset}
|
||||
{is_modified}
|
||||
title={Some(format!("{}-label", props.field_key))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
mod row_selector;
|
||||
|
||||
mod symbol_pairs;
|
||||
mod symbol_pairs_item;
|
||||
mod symbol_selector;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use yew::{Callback, Html, Properties, html};
|
||||
|
||||
use crate::components::column_settings_sidebar::style_tab::symbol::symbol_pairs::PairsList;
|
||||
use crate::components::filter_dropdown::{FilterDropDownElement, FilterDropDownPortal};
|
||||
use crate::config::{ColumnConfigFieldUpdate, KeyValueOpts, SymbolKVPair};
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct SymbolAttrProps {
|
||||
pub session: Session,
|
||||
pub column_name: String,
|
||||
pub restored_config: Option<HashMap<String, String>>,
|
||||
pub on_change: Callback<ColumnConfigFieldUpdate>,
|
||||
pub default_config: KeyValueOpts,
|
||||
/// Selected theme name, threaded for PortalModal consumers.
|
||||
pub selected_theme: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
impl SymbolAttrProps {
|
||||
pub fn next_default_symbol(&self, pairs_len: usize) -> String {
|
||||
let values = &self.default_config.values;
|
||||
values.get(pairs_len % values.len()).cloned().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SymbolAttrMsg {
|
||||
UpdatePairs(Vec<SymbolKVPair>),
|
||||
}
|
||||
|
||||
pub struct SymbolStyle {
|
||||
pairs: Vec<SymbolKVPair>,
|
||||
row_dropdown: Rc<FilterDropDownElement>,
|
||||
}
|
||||
|
||||
impl yew::Component for SymbolStyle {
|
||||
type Message = SymbolAttrMsg;
|
||||
type Properties = SymbolAttrProps;
|
||||
|
||||
fn create(ctx: &yew::Context<Self>) -> Self {
|
||||
let pairs = ctx
|
||||
.props()
|
||||
.restored_config
|
||||
.as_ref()
|
||||
.map(|restored_config| {
|
||||
let mut pairs = restored_config
|
||||
.iter()
|
||||
.map(|(key, value)| SymbolKVPair::new(Some(key.to_owned()), value.to_owned()))
|
||||
.collect_vec();
|
||||
|
||||
pairs.push(SymbolKVPair::new(
|
||||
None,
|
||||
ctx.props().next_default_symbol(pairs.len()),
|
||||
));
|
||||
pairs
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let row_dropdown = Rc::new(FilterDropDownElement::new(ctx.props().session.clone()));
|
||||
Self {
|
||||
pairs,
|
||||
row_dropdown,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
SymbolAttrMsg::UpdatePairs(mut new_pairs) => {
|
||||
let symbols = new_pairs
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter_map(|pair| Some((pair.key?, pair.value)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut value = serde_json::Map::new();
|
||||
if !symbols.is_empty() {
|
||||
value.insert(
|
||||
"symbols".to_owned(),
|
||||
serde_json::to_value(&symbols).unwrap_or(serde_json::Value::Null),
|
||||
);
|
||||
}
|
||||
ctx.props().on_change.emit(ColumnConfigFieldUpdate {
|
||||
keys: ctx.props().keys.clone(),
|
||||
value,
|
||||
});
|
||||
|
||||
let has_last_key = new_pairs
|
||||
.last()
|
||||
.map(|pair| pair.key.is_some())
|
||||
.unwrap_or_default();
|
||||
|
||||
if has_last_key {
|
||||
let val = ctx.props().next_default_symbol(new_pairs.len());
|
||||
new_pairs.push(SymbolKVPair::new(None, val))
|
||||
}
|
||||
|
||||
self.pairs = new_pairs;
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &yew::Context<Self>) -> Html {
|
||||
let update_pairs = ctx.link().callback(SymbolAttrMsg::UpdatePairs);
|
||||
html! {
|
||||
<>
|
||||
<PairsList
|
||||
title="Symbols"
|
||||
id="attributes-symbols"
|
||||
pairs={self.pairs.clone()}
|
||||
row_dropdown={self.row_dropdown.clone()}
|
||||
column_name={ctx.props().column_name.clone()}
|
||||
values={Rc::new(ctx.props().default_config.values.clone())}
|
||||
{update_pairs}
|
||||
/>
|
||||
<FilterDropDownPortal
|
||||
element={(*self.row_dropdown).clone()}
|
||||
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use perspective_client::clone;
|
||||
use yew::{Html, Properties, function_component, html};
|
||||
|
||||
use crate::components::empty_row::EmptyRow;
|
||||
use crate::components::filter_dropdown::FilterDropDownElement;
|
||||
use crate::config::SymbolKVPair;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RowSelectorProps {
|
||||
pub selected_row: Option<String>,
|
||||
pub on_select: yew::Callback<String>,
|
||||
pub dropdown: Rc<FilterDropDownElement>,
|
||||
pub pairs: Vec<SymbolKVPair>,
|
||||
pub index: usize,
|
||||
pub focused: bool,
|
||||
pub set_focused_index: yew::Callback<Option<usize>>,
|
||||
pub column_name: String,
|
||||
}
|
||||
|
||||
#[function_component(RowSelector)]
|
||||
pub fn row_selector(props: &RowSelectorProps) -> Html {
|
||||
let on_select = {
|
||||
clone!(props.dropdown, props.index, props.set_focused_index);
|
||||
props.on_select.reform(move |key| {
|
||||
dropdown.hide().unwrap();
|
||||
set_focused_index.emit(Some(index + 1));
|
||||
key
|
||||
})
|
||||
};
|
||||
|
||||
let ondblclick = {
|
||||
clone!(props.set_focused_index, props.index);
|
||||
yew::Callback::from(move |_| {
|
||||
set_focused_index.emit(Some(index));
|
||||
})
|
||||
};
|
||||
|
||||
let err_class = (props.index != props.pairs.len() - 1).then_some("row-selector-errored");
|
||||
|
||||
let inner = if props.selected_row.is_none() || props.focused {
|
||||
let mut pairs = props.pairs.clone();
|
||||
if let Some(ref rowval) = props.selected_row
|
||||
&& let Some((i, _)) = pairs.iter().find_position(|pair| {
|
||||
pair.key
|
||||
.as_ref()
|
||||
.map(|keyval| keyval == rowval)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
{
|
||||
pairs.remove(i);
|
||||
}
|
||||
|
||||
let exclude: HashSet<_> = pairs
|
||||
.into_iter()
|
||||
.filter_map(|SymbolKVPair { key, .. }| key)
|
||||
.collect();
|
||||
|
||||
html! {
|
||||
<div class={err_class}>
|
||||
<EmptyRow
|
||||
dropdown={props.dropdown.clone()}
|
||||
{exclude}
|
||||
{on_select}
|
||||
focused={props.focused}
|
||||
index={props.index}
|
||||
set_focused_index={props.set_focused_index.clone()}
|
||||
value={props.selected_row.clone().unwrap_or_default()}
|
||||
column_name={props.column_name.clone()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="row-selector column-selector-column">
|
||||
<div class="column-selector-column-border">
|
||||
<span class="column_name none" {ondblclick}>
|
||||
{ props.selected_row.clone().unwrap() }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
html! { <div class="row-selector">{ inner }</div> }
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use yew::{Callback, Html, Properties, html};
|
||||
|
||||
use crate::components::column_settings_sidebar::style_tab::symbol::symbol_pairs_item::PairsListItem;
|
||||
use crate::components::filter_dropdown::FilterDropDownElement;
|
||||
use crate::config::SymbolKVPair;
|
||||
use crate::utils::PtrEqRc;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PairsListProps {
|
||||
pub title: String,
|
||||
pub pairs: Vec<SymbolKVPair>,
|
||||
pub update_pairs: Callback<Vec<SymbolKVPair>>,
|
||||
pub id: Option<String>,
|
||||
pub row_dropdown: Rc<FilterDropDownElement>,
|
||||
pub values: PtrEqRc<Vec<String>>,
|
||||
pub column_name: String,
|
||||
}
|
||||
|
||||
pub enum PairsListMsg {
|
||||
SetNextFocus(Option<usize>),
|
||||
}
|
||||
|
||||
pub struct PairsList {
|
||||
next_focus: Option<usize>,
|
||||
}
|
||||
|
||||
impl yew::Component for PairsList {
|
||||
type Message = PairsListMsg;
|
||||
type Properties = PairsListProps;
|
||||
|
||||
fn create(_ctx: &yew::Context<Self>) -> Self {
|
||||
Self { next_focus: None }
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
PairsListMsg::SetNextFocus(i) => self.next_focus = i,
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &yew::Context<Self>) -> Html {
|
||||
let props = ctx.props();
|
||||
let set_focused = ctx.link().callback(PairsListMsg::SetNextFocus);
|
||||
let main_pairs = props
|
||||
.pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, pair)| {
|
||||
let focused = self.next_focus.map(|s| s == index).unwrap_or_default();
|
||||
html! {
|
||||
<PairsListItem
|
||||
pair={pair.clone()}
|
||||
{index}
|
||||
pairs={props.pairs.clone()}
|
||||
row_dropdown={props.row_dropdown.clone()}
|
||||
values={props.values.clone()}
|
||||
update_pairs={props.update_pairs.clone()}
|
||||
set_focused_index={set_focused.clone()}
|
||||
column_name={props.column_name.clone()}
|
||||
{focused}
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div class="pairs-list" id={props.id.clone()} data-label={props.title.clone()}>
|
||||
<ul>{ for main_pairs }</ul>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use yew::{Callback, Html, Properties, html};
|
||||
|
||||
use crate::components::column_settings_sidebar::style_tab::symbol::row_selector::RowSelector;
|
||||
use crate::components::column_settings_sidebar::style_tab::symbol::symbol_selector::SymbolSelector;
|
||||
use crate::components::filter_dropdown::FilterDropDownElement;
|
||||
use crate::config::SymbolKVPair;
|
||||
use crate::utils::PtrEqRc;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PairsListItemProps {
|
||||
pub pair: SymbolKVPair,
|
||||
pub index: usize,
|
||||
pub pairs: Vec<SymbolKVPair>,
|
||||
pub update_pairs: Callback<Vec<SymbolKVPair>>,
|
||||
pub row_dropdown: Rc<FilterDropDownElement>,
|
||||
pub values: PtrEqRc<Vec<String>>,
|
||||
pub focused: bool,
|
||||
pub set_focused_index: Callback<Option<usize>>,
|
||||
pub column_name: String,
|
||||
}
|
||||
|
||||
pub enum PairListItemMsg {
|
||||
Remove,
|
||||
UpdateKey(Option<String>),
|
||||
UpdateValue(String),
|
||||
}
|
||||
|
||||
pub struct PairsListItem {}
|
||||
impl yew::Component for PairsListItem {
|
||||
type Message = PairListItemMsg;
|
||||
type Properties = PairsListItemProps;
|
||||
|
||||
fn create(_ctx: &yew::Context<Self>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
|
||||
let p = ctx.props();
|
||||
match msg {
|
||||
PairListItemMsg::Remove => {
|
||||
let mut new_pairs = p.pairs.clone();
|
||||
new_pairs.remove(p.index);
|
||||
p.update_pairs.emit(new_pairs);
|
||||
true
|
||||
},
|
||||
PairListItemMsg::UpdateKey(key) => {
|
||||
let next = p.pair.update_key(key);
|
||||
let mut new_pairs = p.pairs.clone();
|
||||
new_pairs[p.index] = next;
|
||||
p.update_pairs.emit(new_pairs);
|
||||
true
|
||||
},
|
||||
PairListItemMsg::UpdateValue(val) => {
|
||||
let next = p.pair.update_value(val);
|
||||
let mut new_pairs = p.pairs.clone();
|
||||
new_pairs[p.index] = next;
|
||||
p.update_pairs.emit(new_pairs);
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &yew::Context<Self>) -> Html {
|
||||
let props = ctx.props();
|
||||
let on_remove = ctx.link().callback(|_| PairListItemMsg::Remove);
|
||||
let on_key_update = ctx.link().callback(|s| PairListItemMsg::UpdateKey(Some(s)));
|
||||
let on_value_update = ctx.link().callback(PairListItemMsg::UpdateValue);
|
||||
|
||||
let remove_style =
|
||||
(ctx.props().index == ctx.props().pairs.len() - 1).then_some("visibility: hidden");
|
||||
|
||||
html! {
|
||||
<li class="pairs-list-item">
|
||||
<RowSelector
|
||||
selected_row={props.pair.key.clone()}
|
||||
on_select={on_key_update.clone()}
|
||||
dropdown={props.row_dropdown.clone()}
|
||||
pairs={props.pairs.clone()}
|
||||
index={props.index}
|
||||
focused={props.focused}
|
||||
set_focused_index={props.set_focused_index.clone()}
|
||||
column_name={props.column_name.clone()}
|
||||
/>
|
||||
<SymbolSelector
|
||||
index={props.index}
|
||||
callback={on_value_update}
|
||||
values={props.values.clone()}
|
||||
selected_value={props.pair.value.clone()}
|
||||
/>
|
||||
<span
|
||||
class="toggle-mode is_column_active"
|
||||
style={remove_style}
|
||||
onclick={on_remove.clone()}
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use itertools::Itertools;
|
||||
use yew::{Callback, Html, Properties, function_component, html};
|
||||
|
||||
use crate::components::containers::select::{Select, SelectItem};
|
||||
use crate::utils::PtrEqRc;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SymbolSelectorProps {
|
||||
pub index: usize,
|
||||
pub selected_value: Option<String>,
|
||||
pub values: PtrEqRc<Vec<String>>,
|
||||
pub callback: Callback<String>,
|
||||
}
|
||||
|
||||
#[function_component(SymbolSelector)]
|
||||
pub fn symbol_selector(p: &SymbolSelectorProps) -> Html {
|
||||
let values = yew::use_memo(p.values.clone(), |values| {
|
||||
values.iter().cloned().map(SelectItem::Option).collect_vec()
|
||||
});
|
||||
|
||||
let selected = p
|
||||
.values
|
||||
.iter()
|
||||
.find(|sym| {
|
||||
p.selected_value
|
||||
.as_ref()
|
||||
.map(|selected| *sym == selected)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| p.values.get(p.index % values.len()).cloned().unwrap());
|
||||
|
||||
html! {
|
||||
<Select<String>
|
||||
wrapper_class="symbol-selector-wrapper"
|
||||
class="symbol-selector"
|
||||
on_select={p.callback.clone()}
|
||||
{values}
|
||||
{selected}
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use derivative::Derivative;
|
||||
use perspective_client::proto::ColumnType;
|
||||
use web_sys::*;
|
||||
use yew::html::Scope;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::column_dropdown::ColumnDropDownElement;
|
||||
use crate::components::column_selector::{EmptyColumn, InPlaceColumn, InvalidColumn};
|
||||
use crate::components::type_icon::TypeIcon;
|
||||
use crate::presentation::{DragDropContainer, Presentation};
|
||||
use crate::utils::DragTarget;
|
||||
|
||||
#[derive(Properties, Derivative)]
|
||||
#[derivative(Clone(bound = ""))]
|
||||
pub struct DragDropListProps<T, U>
|
||||
where
|
||||
T: Component,
|
||||
U: Component,
|
||||
<U as Component>::Properties: DragDropListItemProps,
|
||||
{
|
||||
pub parent: Scope<T>,
|
||||
|
||||
pub presentation: Presentation,
|
||||
pub name: &'static str,
|
||||
pub column_dropdown: ColumnDropDownElement,
|
||||
pub exclude: HashSet<String>,
|
||||
pub children: ChildrenWithProps<U>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub disabled: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub is_dragover: Option<(
|
||||
usize,
|
||||
<<U as Component>::Properties as DragDropListItemProps>::Item,
|
||||
)>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub allow_duplicates: bool,
|
||||
}
|
||||
|
||||
impl<T, U> PartialEq for DragDropListProps<T, U>
|
||||
where
|
||||
T: Component,
|
||||
U: Component,
|
||||
<U as Component>::Properties: DragDropListItemProps,
|
||||
{
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.name == other.name
|
||||
&& self.children == other.children
|
||||
&& self.allow_duplicates == other.allow_duplicates
|
||||
&& self.is_dragover == other.is_dragover
|
||||
&& self.disabled == other.disabled
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DragDropListMsg {
|
||||
Freeze(bool),
|
||||
}
|
||||
|
||||
/// A sub-selector for a list-like component of a `JsViewConfig`, such as
|
||||
/// `filters` and `sort`.
|
||||
///
|
||||
/// `DragDropList` is parameterized by two `Component`
|
||||
/// types, the parent component `T` and the inner item compnent `U`, which must
|
||||
/// additionally implement `DragDropListItemProps` trait on its own `Properties`
|
||||
/// associated type.
|
||||
///
|
||||
/// Before you ask: yes, `frozen_size` needs to be a float64 since `flex`
|
||||
/// containers can have fractional dimensions.
|
||||
pub struct DragDropList<T, U, V>
|
||||
where
|
||||
T: Component,
|
||||
U: Component,
|
||||
<U as Component>::Properties: DragDropListItemProps,
|
||||
V: DragContext<T::Message> + 'static,
|
||||
{
|
||||
parent_type: PhantomData<T>,
|
||||
item_type: PhantomData<U>,
|
||||
draggable_type: PhantomData<V>,
|
||||
elem: NodeRef,
|
||||
frozen_size: Option<f64>,
|
||||
}
|
||||
|
||||
impl<T, U, V> Component for DragDropList<T, U, V>
|
||||
where
|
||||
T: Component,
|
||||
U: Component,
|
||||
<U as Component>::Properties: DragDropListItemProps,
|
||||
V: DragContext<T::Message> + 'static,
|
||||
{
|
||||
type Message = DragDropListMsg;
|
||||
type Properties = DragDropListProps<T, U>;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
parent_type: PhantomData,
|
||||
item_type: PhantomData,
|
||||
draggable_type: PhantomData,
|
||||
elem: NodeRef::default(),
|
||||
frozen_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
// When a dragover occurs and a new Column is inserted into the selector,
|
||||
// the geometry of the selector may expand and cause a parent reflow,
|
||||
// which annoyingly changes the drag status and glitchiness occurs.
|
||||
// By using `Freeze` when a dragenter occurs, the element's width will be
|
||||
// frozen until `drop` or `dragleave`.
|
||||
DragDropListMsg::Freeze(freeze) => {
|
||||
if freeze && self.frozen_size.is_none() {
|
||||
let elem = self.elem.cast::<HtmlElement>().unwrap();
|
||||
self.frozen_size = Some({
|
||||
// `offset_width` and family are `i32`, but Chrome _really_
|
||||
// uses fractional pixel widths for these which can only be
|
||||
// recovered by parsing the generated stylesheet ...
|
||||
let txt = window()
|
||||
.unwrap()
|
||||
.get_computed_style(&elem)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get_property_value("width")
|
||||
.unwrap();
|
||||
|
||||
// Strip "px" suffix, e.g. "24.876px".
|
||||
let px = &txt[..txt.len() - 2];
|
||||
px.parse::<f64>().unwrap()
|
||||
});
|
||||
true
|
||||
} else if !freeze {
|
||||
// Don't render because the invoker will do so through `dragdrop`.
|
||||
self.frozen_size = None;
|
||||
false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let dragover = Callback::from(|_event: DragEvent| _event.prevent_default());
|
||||
|
||||
// On dragleave, signal the parent but no need to redraw as parent will call
|
||||
// `change()` when resetting props.
|
||||
let drag_container = DragDropContainer::new(
|
||||
{
|
||||
let total = ctx.props().children.len();
|
||||
let parent = ctx.props().parent.clone();
|
||||
let link = ctx.link().clone();
|
||||
move || {
|
||||
link.send_message(DragDropListMsg::Freeze(true));
|
||||
parent.send_message(V::dragenter(total))
|
||||
}
|
||||
},
|
||||
{
|
||||
let parent = ctx.props().parent.clone();
|
||||
let link = ctx.link().clone();
|
||||
move || {
|
||||
link.send_message(DragDropListMsg::Freeze(false));
|
||||
parent.send_message(V::dragleave())
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let drop = Callback::from({
|
||||
let presentation = ctx.props().presentation.clone();
|
||||
let link = ctx.link().clone();
|
||||
move |event| {
|
||||
link.send_message(DragDropListMsg::Freeze(false));
|
||||
presentation.notify_drop(&event);
|
||||
}
|
||||
});
|
||||
|
||||
// Held by per-row `ondragenter` closures below so they can re-arm
|
||||
// the `safaridragleave` flag on the container element when the
|
||||
// row stops dragenter from bubbling. See the comment inside the
|
||||
// closure for why this matters.
|
||||
let container_noderef = drag_container.noderef.clone();
|
||||
|
||||
let invalid_drag: bool;
|
||||
let mut valid_duplicate_drag = false;
|
||||
|
||||
let columns_html = {
|
||||
let mut columns = ctx
|
||||
.props()
|
||||
.children
|
||||
.iter()
|
||||
.map(|x| (true, Some(x)))
|
||||
.enumerate()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
invalid_drag = if let Some((x, column)) = &ctx.props().is_dragover {
|
||||
let index = *x;
|
||||
let is_append = index == columns.len();
|
||||
let is_self_move = ctx
|
||||
.props()
|
||||
.presentation
|
||||
.get_drag_target()
|
||||
.map(|x| V::is_self_move(x))
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_duplicate = columns
|
||||
.iter()
|
||||
.position(|x| x.1.1.as_ref().unwrap().props.get_item() == *column);
|
||||
|
||||
valid_duplicate_drag = is_duplicate.is_some() && !ctx.props().allow_duplicates;
|
||||
if let Some(duplicate) = is_duplicate
|
||||
&& !is_append
|
||||
&& (!ctx.props().allow_duplicates || is_self_move)
|
||||
{
|
||||
columns.remove(duplicate);
|
||||
}
|
||||
|
||||
// If inserting into the middle of the list, use
|
||||
// the length of the existing element to prevent
|
||||
// jitter as the underlying dragover zone moves.
|
||||
if index < columns.len() {
|
||||
columns.insert(index, (usize::MAX, (false, None)));
|
||||
false
|
||||
} else if (!is_append && !ctx.props().allow_duplicates)
|
||||
|| ((!is_append || !is_self_move)
|
||||
&& (is_duplicate.is_none() || ctx.props().allow_duplicates))
|
||||
{
|
||||
columns.push((usize::MAX, (false, None)));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
columns
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, column)| {
|
||||
let close = ctx.props().parent.callback(move |_| V::close(idx));
|
||||
let dragenter = ctx.props().parent.callback({
|
||||
let link = ctx.link().clone();
|
||||
let container_noderef = container_noderef.clone();
|
||||
move |event: DragEvent| {
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
// Safari: `relatedTarget` is always null on
|
||||
// dragleave, so `dragleave_helper` uses a
|
||||
// `data-safaridragleave` flag set by the
|
||||
// container's own dragenter to distinguish
|
||||
// child-crossing leaves (consume the flag)
|
||||
// from real leaves (no flag → fire callback).
|
||||
// The `stop_propagation` above blocks the
|
||||
// container's dragenter, so the flag would
|
||||
// never be re-armed after the first consume —
|
||||
// any further internal boundary crossing
|
||||
// would demote the state out of
|
||||
// `DragOverInProgress` and the next drop
|
||||
// would be silently rejected. Set the flag
|
||||
// here so each row-targeted dragenter
|
||||
// refills the pool.
|
||||
if event.related_target().is_none()
|
||||
&& let Some(elem) = container_noderef.cast::<HtmlElement>()
|
||||
{
|
||||
let _ = elem.dataset().set("safaridragleave", "true");
|
||||
}
|
||||
link.send_message(DragDropListMsg::Freeze(true));
|
||||
V::dragenter(idx)
|
||||
}
|
||||
});
|
||||
|
||||
if let (key, (true, Some(column))) = column {
|
||||
html! {
|
||||
<div {key} class="pivot-column" ondragenter={dragenter}>
|
||||
{ Html::from(column) }
|
||||
<span class="row_close" onmousedown={close} />
|
||||
</div>
|
||||
}
|
||||
} else if let (key, (_, Some(column))) = column {
|
||||
html! {
|
||||
<div {key} class="pivot-column" ondragenter={dragenter}>
|
||||
{ Html::from(column) }
|
||||
<span class="row_close" style="opacity: 0.3" />
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
let (key, _) = column;
|
||||
html! {
|
||||
<div {key} class="pivot-column" ondragenter={dragenter}>
|
||||
<div class="config-drop" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Html>()
|
||||
};
|
||||
|
||||
let column_dropdown = ctx.props().column_dropdown.clone();
|
||||
let exclude = ctx.props().exclude.clone();
|
||||
let on_select = ctx.props().parent.callback(V::create);
|
||||
let class = classes!("rrow");
|
||||
let is_enabled = true;
|
||||
|
||||
html! {
|
||||
<div ref={&self.elem} {class}>
|
||||
<div
|
||||
id={ctx.props().name}
|
||||
ondragover={is_enabled.then_some(dragover)}
|
||||
ondragenter={is_enabled.then_some(drag_container.dragenter)}
|
||||
ondragleave={is_enabled.then_some(drag_container.dragleave)}
|
||||
ref={drag_container.noderef}
|
||||
ondrop={is_enabled.then_some(drop)}
|
||||
>
|
||||
<div class="psp-text-field">
|
||||
<ul class="psp-text-field__input" for={ctx.props().name}>
|
||||
{ columns_html }
|
||||
if ctx.props().disabled && ctx.props().is_dragover.is_none() {
|
||||
<div class="pivot-column">
|
||||
<div class="pivot-column-border pivot-column-total">
|
||||
<span class="drag-handle icon" />
|
||||
<TypeIcon ty={ColumnType::Integer} />
|
||||
<span class="column_name">{ "TOTAL" }</span>
|
||||
</div>
|
||||
<span
|
||||
class="toggle-mode is_column_active"
|
||||
onmousedown={ctx.props().parent.callback(move |_| V::close(0))}
|
||||
/>
|
||||
</div>
|
||||
} else if ctx.props().is_dragover.is_none() | (!invalid_drag && valid_duplicate_drag) {
|
||||
<EmptyColumn {column_dropdown} {exclude} {on_select} />
|
||||
} else if invalid_drag {
|
||||
<InvalidColumn />
|
||||
}
|
||||
</ul>
|
||||
<label class="pivot-selector-label" for={ctx.props().name} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Must be implemented by `Properties` of children of `DragDropList`, returning
|
||||
/// the value a DragDropItem represents.
|
||||
pub trait DragDropListItemProps: Properties {
|
||||
type Item: Clone + PartialEq;
|
||||
fn get_item(&self) -> Self::Item;
|
||||
}
|
||||
|
||||
pub trait DragContext<T> {
|
||||
fn close(index: usize) -> T;
|
||||
fn dragleave() -> T;
|
||||
fn dragenter(index: usize) -> T;
|
||||
fn create(col: InPlaceColumn) -> T;
|
||||
fn is_self_move(effect: DragTarget) -> bool;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::rc::Rc;
|
||||
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::select::SelectItem;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct DropDownMenuProps<T>
|
||||
where
|
||||
T: Into<Html> + Clone + PartialEq + 'static,
|
||||
{
|
||||
pub values: Rc<Vec<DropDownMenuItem<T>>>,
|
||||
pub callback: Callback<T>,
|
||||
}
|
||||
|
||||
pub struct DropDownMenu<T>
|
||||
where
|
||||
T: Into<Html> + Clone + PartialEq + 'static,
|
||||
{
|
||||
_props: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T> Component for DropDownMenu<T>
|
||||
where
|
||||
T: Into<Html> + Clone + PartialEq + 'static,
|
||||
{
|
||||
type Message = ();
|
||||
type Properties = DropDownMenuProps<T>;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
_props: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, _msg: Self::Message) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let values = &ctx.props().values;
|
||||
let body = if !values.is_empty() {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| match value {
|
||||
DropDownMenuItem::Option(x) => {
|
||||
let click = ctx.props().callback.reform({
|
||||
let value = x.clone();
|
||||
move |_: MouseEvent| value.clone()
|
||||
});
|
||||
|
||||
html! {
|
||||
<span onmousedown={click} class="selected">{ x.clone().into() }</span>
|
||||
}
|
||||
},
|
||||
DropDownMenuItem::OptGroup(name, xs) => {
|
||||
html! {
|
||||
<>
|
||||
<span class="dropdown-group-label">{ name.as_ref() }</span>
|
||||
<div class="dropdown-group-container">
|
||||
{ xs.iter().map(|x| {
|
||||
let click = ctx.props().callback.reform({
|
||||
let value = x.clone();
|
||||
move |_: MouseEvent| value.clone()
|
||||
});
|
||||
html! {
|
||||
<span onmousedown={ click }>
|
||||
{ x.clone().into() }
|
||||
</span>
|
||||
}
|
||||
}).collect::<Html>() }
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect::<Html>()
|
||||
} else {
|
||||
html! { <span class="no-results">{ "No Completions" }</span> }
|
||||
};
|
||||
|
||||
html! { <>{ body }</> }
|
||||
}
|
||||
}
|
||||
|
||||
pub type DropDownMenuItem<T> = SelectItem<T>;
|
||||
@@ -0,0 +1,24 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
//! `containers` are generic container-like components for collections of other
|
||||
//! `Component` types.
|
||||
|
||||
pub mod dragdrop_list;
|
||||
pub mod dropdown_menu;
|
||||
pub mod scroll_panel;
|
||||
pub mod scroll_panel_item;
|
||||
pub mod select;
|
||||
pub mod sidebar;
|
||||
pub mod sidebar_close_button;
|
||||
pub mod split_panel;
|
||||
pub mod tab_list;
|
||||
@@ -0,0 +1,330 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
// Forked from https://github.com/AircastDev/yew-virtual-scroller (Apache 2.0)
|
||||
// Adds support for Yew 0.19, auto-width and a simplified message structure.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use web_sys::Element;
|
||||
use yew::prelude::*;
|
||||
use yew::virtual_dom::VChild;
|
||||
|
||||
use super::scroll_panel_item::ScrollPanelItem;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct ScrollPanelProps {
|
||||
#[prop_or_default]
|
||||
pub children: Vec<VChild<ScrollPanelItem>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub viewport_ref: Option<NodeRef>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub initial_width: Option<f64>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub class: Classes,
|
||||
|
||||
#[prop_or_default]
|
||||
pub id: &'static str,
|
||||
|
||||
#[prop_or_default]
|
||||
pub dragenter: Callback<DragEvent>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub dragover: Callback<DragEvent>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub dragleave: Callback<DragEvent>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_resize: Option<Rc<PubSub<()>>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_auto_width: Callback<f64>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_dimensions_reset: Option<Rc<PubSub<()>>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub drop: Callback<DragEvent>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub omit_autosize_div: bool,
|
||||
}
|
||||
|
||||
impl ScrollPanelProps {
|
||||
/// Calculate the total virtual height of this scroll panel from the `size`
|
||||
/// prop of its children.
|
||||
fn total_height(&self) -> f64 {
|
||||
self.children
|
||||
.iter()
|
||||
.map(|x| x.props.get_size())
|
||||
.reduce(|x, y| x + y)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ScrollPanelProps {
|
||||
fn eq(&self, _rhs: &Self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub enum ScrollPanelMsg {
|
||||
CalculateWindowContent,
|
||||
UpdateViewportDimensions,
|
||||
ResetAutoWidth,
|
||||
ChildrenChanged,
|
||||
}
|
||||
|
||||
pub struct ScrollPanel {
|
||||
viewport_ref: NodeRef,
|
||||
viewport_height: f64,
|
||||
viewport_width: f64,
|
||||
content_window: Option<ContentWindow>,
|
||||
needs_rerender: bool,
|
||||
total_height: f64,
|
||||
_dimensions_reset_sub: Option<Subscription>,
|
||||
_resize_sub: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl Component for ScrollPanel {
|
||||
type Message = ScrollPanelMsg;
|
||||
type Properties = ScrollPanelProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let _dimensions_reset_sub = ctx.props().on_dimensions_reset.as_ref().map(|pubsub| {
|
||||
let link = ctx.link().clone();
|
||||
pubsub.add_listener(move |_| {
|
||||
link.send_message_batch(vec![
|
||||
ScrollPanelMsg::ResetAutoWidth,
|
||||
ScrollPanelMsg::CalculateWindowContent,
|
||||
])
|
||||
})
|
||||
});
|
||||
|
||||
let _resize_sub = ctx.props().on_resize.as_ref().map(|pubsub| {
|
||||
let link = ctx.link().clone();
|
||||
pubsub.add_listener(move |_| {
|
||||
link.send_message_batch(vec![
|
||||
ScrollPanelMsg::UpdateViewportDimensions,
|
||||
ScrollPanelMsg::CalculateWindowContent,
|
||||
])
|
||||
})
|
||||
});
|
||||
|
||||
let total_height = ctx.props().total_height();
|
||||
Self {
|
||||
viewport_ref: Default::default(),
|
||||
viewport_height: 0f64,
|
||||
viewport_width: ctx.props().initial_width.unwrap_or_default(),
|
||||
content_window: None,
|
||||
needs_rerender: true,
|
||||
total_height,
|
||||
_dimensions_reset_sub,
|
||||
_resize_sub,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ScrollPanelMsg::ResetAutoWidth => {
|
||||
self.viewport_width = 0.0;
|
||||
self.calculate_window_content(ctx)
|
||||
},
|
||||
|
||||
// TODO don't hardcode pixel offsets - fix the CSS container to not
|
||||
// require pixel offsets to work.
|
||||
ScrollPanelMsg::UpdateViewportDimensions => {
|
||||
let viewport = self.viewport_elem(ctx);
|
||||
let rect = viewport.get_bounding_client_rect();
|
||||
let viewport_height = rect.height() - 8.0;
|
||||
let scroll_offset = if ctx.props().omit_autosize_div {
|
||||
0.0
|
||||
} else {
|
||||
6.0
|
||||
};
|
||||
|
||||
let viewport_width = self.viewport_width.max(rect.width() - scroll_offset);
|
||||
let re_render = self.viewport_height != viewport_height
|
||||
|| self.viewport_width != viewport_width;
|
||||
|
||||
self.viewport_height = rect.height() - 8.0;
|
||||
self.viewport_width = self.viewport_width.max(rect.width() - scroll_offset);
|
||||
ctx.props().on_auto_width.emit(self.viewport_width);
|
||||
re_render
|
||||
},
|
||||
ScrollPanelMsg::CalculateWindowContent => self.calculate_window_content(ctx),
|
||||
ScrollPanelMsg::ChildrenChanged => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// If the new total row height is different than last time this component
|
||||
/// was rendered, we need to double-render to read the container's
|
||||
/// potentially updated height.
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
let total_height = ctx.props().total_height();
|
||||
self.needs_rerender =
|
||||
self.needs_rerender || (self.total_height - total_height).abs() > 0.1f64;
|
||||
self.total_height = total_height;
|
||||
ctx.link().send_message_batch(vec![
|
||||
ScrollPanelMsg::UpdateViewportDimensions,
|
||||
ScrollPanelMsg::CalculateWindowContent,
|
||||
ScrollPanelMsg::ChildrenChanged,
|
||||
]);
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let content_style = format!("height:{}px", self.total_height);
|
||||
let (window_style, windowed_items) = match &self.content_window {
|
||||
None => ("".to_string(), &[][..]),
|
||||
Some(cw) => (
|
||||
format!(
|
||||
"position:sticky;top:0;transform:translateY({}px);",
|
||||
cw.start_y - cw.scroll_top
|
||||
),
|
||||
(&ctx.props().children[cw.visible_range.clone()]),
|
||||
),
|
||||
};
|
||||
|
||||
let width_style = format!("width:{}px", self.viewport_width.max(0.0));
|
||||
let items = if !windowed_items.is_empty() {
|
||||
let onscroll = ctx.link().batch_callback(|_| {
|
||||
vec![
|
||||
ScrollPanelMsg::UpdateViewportDimensions,
|
||||
ScrollPanelMsg::CalculateWindowContent,
|
||||
]
|
||||
});
|
||||
|
||||
// TODO This glitches - we should use the `sticky` positioning strategy that
|
||||
// `regular-table` uses.
|
||||
html! {
|
||||
<div
|
||||
ref={self.viewport(ctx)}
|
||||
id={ctx.props().id}
|
||||
{onscroll}
|
||||
ondragover={&ctx.props().dragover}
|
||||
ondragenter={&ctx.props().dragenter}
|
||||
ondragleave={&ctx.props().dragleave}
|
||||
ondrop={&ctx.props().drop}
|
||||
class={ctx.props().class.clone()}
|
||||
>
|
||||
<div class="scroll-panel-container" style={window_style}>
|
||||
{ for windowed_items.iter().cloned().map(Html::from) }
|
||||
if !ctx.props().omit_autosize_div {
|
||||
<div
|
||||
key="__scroll-panel-auto-width__"
|
||||
class="scroll-panel-auto-width"
|
||||
style={width_style}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div class="scroll-panel-content" style={content_style} />
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div
|
||||
ref={self.viewport(ctx)}
|
||||
id={ctx.props().id}
|
||||
class={ctx.props().class.clone()}
|
||||
>
|
||||
<div style={content_style} />
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
html! { <>{ items }</> }
|
||||
}
|
||||
|
||||
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
|
||||
ctx.link().send_message_batch(vec![
|
||||
ScrollPanelMsg::UpdateViewportDimensions,
|
||||
ScrollPanelMsg::CalculateWindowContent,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollPanel {
|
||||
fn viewport<'a, 'b: 'a, 'c: 'a>(&'b self, ctx: &'c Context<Self>) -> &'a NodeRef {
|
||||
ctx.props()
|
||||
.viewport_ref
|
||||
.as_ref()
|
||||
.unwrap_or(&self.viewport_ref)
|
||||
}
|
||||
|
||||
fn viewport_elem(&self, ctx: &Context<Self>) -> Element {
|
||||
self.viewport(ctx).cast::<Element>().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ContentWindow {
|
||||
scroll_top: f64,
|
||||
start_y: f64,
|
||||
visible_range: Range<usize>,
|
||||
}
|
||||
|
||||
impl ScrollPanel {
|
||||
fn calculate_window_content(&mut self, ctx: &Context<Self>) -> bool {
|
||||
let viewport = self.viewport_elem(ctx);
|
||||
let scroll_top = viewport.scroll_top() as f64;
|
||||
let mut start_node = 0;
|
||||
let mut start_y = 0_f64;
|
||||
let mut offset = 0_f64;
|
||||
let end_node = ctx
|
||||
.props()
|
||||
.children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_or_last(|(i, x)| {
|
||||
if offset + x.props.get_size() < scroll_top {
|
||||
start_node = *i + 1;
|
||||
start_y = offset + x.props.get_size();
|
||||
}
|
||||
|
||||
offset += x.props.get_size();
|
||||
offset > scroll_top + self.viewport_height
|
||||
})
|
||||
.map(|x| x.0)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Why is this `end_node + 2`, I can see you asking yourself? `end_node` is the
|
||||
// index of the last visible child, but [`Range`] is an open interval so we must
|
||||
// increment by 1. The next rendered element is always occluded by the parent
|
||||
// container, it may seem unnecessary to render it, however not doing so causing
|
||||
// scroll glitching in Chrome:
|
||||
// * When the first pixel of the `end_node + 1` child is scrolled into view, the
|
||||
// container element it is embedded in will expand past the end of the scroll
|
||||
// container.
|
||||
// * Chrome detects this and helpfully scrolls this new element into view,
|
||||
// re-triggering the on scroll callback.
|
||||
let visible_range = start_node..ctx.props().children.len().min(end_node + 2);
|
||||
let content_window = Some(ContentWindow {
|
||||
scroll_top,
|
||||
start_y,
|
||||
visible_range,
|
||||
});
|
||||
|
||||
let re_render = self.content_window != content_window;
|
||||
self.content_window = content_window;
|
||||
re_render
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
// Forked from https://github.com/AircastDev/yew-virtual-scroller (Apache 2.0)
|
||||
// Adds support for Yew 0.19, auto-width and a simplified message structure.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(PartialEq, Properties)]
|
||||
pub struct ScrollPanelItemProps {
|
||||
pub children: Children,
|
||||
|
||||
/// The expected size of this component in pixels. Calculating this value
|
||||
/// ahead of time makes it easier to implement a high-performance virtual
|
||||
/// renderer without resorting to weird tricks, _but_ checking that this
|
||||
/// hint is correct is nearly as expensive. So, we only generate the
|
||||
/// validation code in debug builds.
|
||||
pub size_hint: f64,
|
||||
|
||||
/// Should we measure the child sizes or just rely on the hint? This
|
||||
/// option may be enabled during debugging to validation render optimization
|
||||
/// via [`Self::size_hint`], but will faile tests due to the extra DOM
|
||||
/// structure it emits.
|
||||
#[prop_or_default]
|
||||
pub is_debug_mode: bool,
|
||||
|
||||
/// Communicate the panel size to virtual parent
|
||||
#[prop_or_default]
|
||||
measured_size: Rc<Cell<Option<f64>>>,
|
||||
}
|
||||
|
||||
impl ScrollPanelItemProps {
|
||||
pub(crate) fn get_size(&self) -> f64 {
|
||||
if self.is_debug_mode {
|
||||
self.measured_size.get().unwrap_or(self.size_hint)
|
||||
} else {
|
||||
self.size_hint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ScrollPanelItem {
|
||||
node: NodeRef,
|
||||
}
|
||||
|
||||
impl Component for ScrollPanelItem {
|
||||
type Message = ();
|
||||
type Properties = ScrollPanelItemProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
if ctx.props().is_debug_mode {
|
||||
ctx.props().measured_size.set(Some(ctx.props().size_hint));
|
||||
}
|
||||
|
||||
Self {
|
||||
node: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
if ctx.props().is_debug_mode {
|
||||
<div class="debug-size-wrapper" style="display: flow-root" ref={self.node.clone()}>
|
||||
for child in ctx.props().children.iter() {
|
||||
{ child }
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
for child in ctx.props().children.iter() {
|
||||
{ child }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
if ctx.props().is_debug_mode {
|
||||
ctx.props().measured_size.set(Some(ctx.props().size_hint));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
|
||||
if first_render && ctx.props().is_debug_mode {
|
||||
let elem = self.node.cast::<web_sys::HtmlElement>().unwrap();
|
||||
let new_height = elem.get_bounding_client_rect().height();
|
||||
if ctx.props().measured_size.get() != Some(new_height) {
|
||||
tracing::warn!(
|
||||
"ScrollPanel size_hint does not match element size: {} != {}",
|
||||
ctx.props().size_hint,
|
||||
new_height
|
||||
);
|
||||
web_sys::console::warn_1(&elem);
|
||||
ctx.props().measured_size.set(Some(new_height));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::borrow::{Borrow, Cow};
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct SelectProps<T>
|
||||
where
|
||||
T: Clone + Display + PartialEq + 'static,
|
||||
{
|
||||
pub values: Rc<Vec<SelectItem<T>>>,
|
||||
pub selected: T,
|
||||
pub on_select: Callback<T>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub is_autosize: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub label: Option<Cow<'static, str>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub id: Option<&'static str>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub class: Option<String>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub wrapper_class: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> PartialEq for SelectProps<T>
|
||||
where
|
||||
T: Clone + Display + PartialEq + 'static,
|
||||
{
|
||||
fn eq(&self, rhs: &Self) -> bool {
|
||||
self.selected == rhs.selected && self.values == rhs.values
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SelectMsg {
|
||||
SelectedChanged(i32),
|
||||
KeyboardInput(bool, i32, String),
|
||||
}
|
||||
|
||||
/// A `<select>` HTML elements, lifted to support parameterization over a set of
|
||||
/// values of a type `T`.
|
||||
pub struct Select<T>
|
||||
where
|
||||
T: Clone + Display + PartialEq + 'static,
|
||||
{
|
||||
select_ref: NodeRef,
|
||||
selected: T,
|
||||
}
|
||||
|
||||
impl<T> Component for Select<T>
|
||||
where
|
||||
T: Clone + Display + PartialEq + 'static,
|
||||
{
|
||||
type Message = SelectMsg;
|
||||
type Properties = SelectProps<T>;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
select_ref: NodeRef::default(),
|
||||
selected: _ctx.props().selected.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
SelectMsg::SelectedChanged(x) => {
|
||||
self.selected = find_nth(x, &ctx.props().values).unwrap().clone();
|
||||
ctx.props().on_select.emit(self.selected.clone());
|
||||
true
|
||||
},
|
||||
|
||||
// All the up and down arrow keys to instantly select, which is
|
||||
// useful for keyboard warrios. This is not ada without the shift
|
||||
// key. I'm not sure if it is _with_ the shift key either, but its
|
||||
// cool.
|
||||
SelectMsg::KeyboardInput(is_shift, idx, code) => {
|
||||
if is_shift {
|
||||
if code.as_str() == "ArrowUp" {
|
||||
if let Some(x) = find_nth(idx - 1, &ctx.props().values) {
|
||||
self.selected = x.clone();
|
||||
ctx.props().on_select.emit(self.selected.clone());
|
||||
return true;
|
||||
}
|
||||
} else if code.as_str() == "ArrowDown"
|
||||
&& let Some(x) = find_nth(idx + 1, &ctx.props().values)
|
||||
{
|
||||
self.selected = x.clone();
|
||||
ctx.props().on_select.emit(self.selected.clone());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The `<select>` has its own state not refelcted by `SelectProps`.
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
self.selected = ctx.props().selected.clone();
|
||||
true
|
||||
}
|
||||
|
||||
// Annoyingly, `<select>` cannot be updated from its HTML alone.
|
||||
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
|
||||
if let Some(elem) = self.select_ref.cast::<web_sys::HtmlSelectElement>() {
|
||||
elem.set_value(&format!("{}", self.selected))
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let callback = ctx.link().callback(|event: InputEvent| {
|
||||
let value = event
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlSelectElement>()
|
||||
.selected_index();
|
||||
SelectMsg::SelectedChanged(value)
|
||||
});
|
||||
|
||||
let key_callback = ctx.link().callback(|event: KeyboardEvent| {
|
||||
event.prevent_default();
|
||||
let value = event
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlSelectElement>()
|
||||
.selected_index();
|
||||
|
||||
let is_shift = event.shift_key();
|
||||
SelectMsg::KeyboardInput(is_shift, value, event.code())
|
||||
});
|
||||
|
||||
let class = if let Some(class) = &ctx.props().class {
|
||||
format!("noselect {class}")
|
||||
} else {
|
||||
"noselect".to_owned()
|
||||
};
|
||||
|
||||
let is_group_selected = !ctx
|
||||
.props()
|
||||
.values
|
||||
.iter()
|
||||
.any(|x| matches!(x, SelectItem::Option(y) if *y == ctx.props().selected));
|
||||
|
||||
let select = html! {
|
||||
<select
|
||||
id={ctx.props().id}
|
||||
{class}
|
||||
ref={&self.select_ref}
|
||||
oninput={callback}
|
||||
onkeydown={key_callback}
|
||||
>
|
||||
{ for ctx.props().values.iter().map(|value| match value {
|
||||
SelectItem::Option(value) => {
|
||||
let selected = *value == ctx.props().selected;
|
||||
html! {
|
||||
<option
|
||||
key={ format!("{}", value) }
|
||||
selected={ selected }
|
||||
value={ format!("{value}") }>
|
||||
{ format!("{}", value) }
|
||||
</option>
|
||||
}
|
||||
},
|
||||
SelectItem::OptGroup(name, group) => html! {
|
||||
<optgroup
|
||||
key={ name.to_string() }
|
||||
label={ name.to_string() }>
|
||||
{
|
||||
for group.iter().map(|value| {
|
||||
let selected =
|
||||
*value == ctx.props().selected;
|
||||
|
||||
let label = format!("{value}");
|
||||
let category: &str = name.borrow();
|
||||
let label = label
|
||||
.strip_prefix(category)
|
||||
.unwrap_or(&label)
|
||||
.trim()
|
||||
.to_owned();
|
||||
|
||||
html! {
|
||||
<option
|
||||
key={ format!("{}", value) }
|
||||
selected={ selected }
|
||||
value={ format!("{value}") }>
|
||||
{ label }
|
||||
</option>
|
||||
}
|
||||
})
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
}) }
|
||||
</select>
|
||||
};
|
||||
|
||||
let wrapper_class = match &ctx.props().wrapper_class {
|
||||
Some(x) => classes!("dropdown-width-container", x),
|
||||
None => classes!("dropdown-width-container"),
|
||||
};
|
||||
|
||||
let value = if ctx.props().is_autosize {
|
||||
Some(self.selected.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
html! {
|
||||
if is_group_selected && ctx.props().label.is_some() {
|
||||
<label>
|
||||
{ ctx.props().label.as_ref().map(|x| x.to_string()).unwrap_or_default() }
|
||||
</label>
|
||||
<div class={wrapper_class} data-value={value}>{ select }</div>
|
||||
} else {
|
||||
<div class={wrapper_class} data-value={value}>{ select }</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub enum SelectItem<T> {
|
||||
Option(T),
|
||||
OptGroup(Cow<'static, str>, Vec<T>),
|
||||
}
|
||||
|
||||
impl<T: Display> SelectItem<T> {
|
||||
pub fn name<'a>(&self) -> Cow<'a, str> {
|
||||
match self {
|
||||
Self::Option(x) => format!("{x}").into(),
|
||||
Self::OptGroup(x, _) => x.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_nth<T>(mut count: i32, items: &[SelectItem<T>]) -> Option<&T> {
|
||||
for ref item in items.iter() {
|
||||
match item {
|
||||
SelectItem::Option(_) if count > 0 => {
|
||||
count -= 1;
|
||||
},
|
||||
SelectItem::OptGroup(_, items) if count >= items.len() as i32 => {
|
||||
count -= items.len() as i32;
|
||||
},
|
||||
SelectItem::OptGroup(_, items) => return items.get(count as usize),
|
||||
SelectItem::Option(x) => return Some(x),
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use perspective_client::clone;
|
||||
use web_sys::Element;
|
||||
use yew::{
|
||||
Callback, Children, Html, Properties, function_component, html, use_effect_with, use_node_ref,
|
||||
use_state_eq,
|
||||
};
|
||||
|
||||
use crate::components::containers::sidebar_close_button::SidebarCloseButton;
|
||||
use crate::components::editable_header::{EditableHeader, EditableHeaderProps};
|
||||
|
||||
#[derive(PartialEq, Clone, Properties)]
|
||||
pub struct SidebarProps {
|
||||
/// The component's children.
|
||||
pub children: Children,
|
||||
|
||||
/// When this callback is called, the sidebar will close
|
||||
pub on_close: Callback<()>,
|
||||
pub id_prefix: String,
|
||||
pub width_override: Option<i32>,
|
||||
pub selected_tab: Option<usize>,
|
||||
pub header_props: EditableHeaderProps,
|
||||
}
|
||||
|
||||
/// Sidebars are designed to live in a
|
||||
/// [`super::split_panel::SplitPanel`]
|
||||
#[function_component]
|
||||
pub fn Sidebar(p: &SidebarProps) -> Html {
|
||||
let id = &p.id_prefix;
|
||||
let noderef = use_node_ref();
|
||||
let auto_width = use_state_eq(|| 0f64);
|
||||
|
||||
// this gets the last calculated width and ensures that
|
||||
// the auto-width element is at least that big.
|
||||
// this ensures the panel grows but does not shrink.
|
||||
use_effect_with(p.clone(), {
|
||||
clone!(noderef, auto_width);
|
||||
move |p| {
|
||||
if p.width_override.is_none() {
|
||||
let updated_width = noderef
|
||||
.cast::<Element>()
|
||||
.map(|el| el.get_bounding_client_rect().width())
|
||||
.unwrap_or_default();
|
||||
let new_auto_width = (*auto_width).max(updated_width);
|
||||
auto_width.set(new_auto_width);
|
||||
} else {
|
||||
auto_width.set(0f64);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let width_style = format!("min-width: 200px; width: {}px", *auto_width);
|
||||
html! {
|
||||
<>
|
||||
<SidebarCloseButton id={format!("{id}_close_button")} on_close_sidebar={&p.on_close} />
|
||||
<div class="sidebar_column" id={format!("{id}_sidebar")} ref={noderef}>
|
||||
<div class="sidebar_header"><EditableHeader ..p.header_props.clone() /></div>
|
||||
<div class="sidebar_border" id={format!("{id}_border")} />
|
||||
{ p.children.iter().collect::<Html>() }
|
||||
<div class="sidebar-auto-width" style={width_style} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::{AttrValue, Callback, Html, Properties, function_component, html};
|
||||
|
||||
#[derive(PartialEq, Clone, Properties)]
|
||||
pub struct SidebarCloseButtonProps {
|
||||
pub on_close_sidebar: Callback<()>,
|
||||
pub id: AttrValue,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn SidebarCloseButton(p: &SidebarCloseButtonProps) -> Html {
|
||||
let onclick = yew::use_callback(p.on_close_sidebar.clone(), |_, cb| cb.emit(()));
|
||||
let id = &p.id;
|
||||
html! {
|
||||
<div {onclick} {id} class="sidebar_close_button">
|
||||
<div class="sidebar_close_button_inner"><span class="icon" /></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::cmp::max;
|
||||
|
||||
use perspective_js::utils::{ApiResult, global};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::HtmlElement;
|
||||
use yew::html::Scope;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, Default)]
|
||||
pub struct SplitPanelProps {
|
||||
pub children: Children,
|
||||
|
||||
#[prop_or_default]
|
||||
pub id: Option<String>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub orientation: Orientation,
|
||||
|
||||
/// Whether to render `<></>` empty templates as empty child panels, or
|
||||
/// omit them entirely.
|
||||
#[prop_or_default]
|
||||
pub skip_empty: bool,
|
||||
|
||||
/// Should the child panels by wrapped in `<div>` elements?
|
||||
#[prop_or_default]
|
||||
pub no_wrap: bool,
|
||||
|
||||
/// Should the panels be rendered/sized in _reverse_ order?
|
||||
#[prop_or_default]
|
||||
pub reverse: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_reset: Option<Callback<()>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_resize: Option<Callback<(i32, i32)>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_resize_finished: Option<Callback<()>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub initial_size: Option<i32>,
|
||||
}
|
||||
|
||||
impl SplitPanelProps {
|
||||
fn validate(&self) -> bool {
|
||||
!self.children.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SplitPanelProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
&& self.children == other.children
|
||||
&& self.orientation == other.orientation
|
||||
&& self.reverse == other.reverse
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SplitPanelMsg {
|
||||
StartResizing(usize, i32, i32, HtmlElement),
|
||||
MoveResizing(i32),
|
||||
StopResizing,
|
||||
Reset(usize),
|
||||
}
|
||||
|
||||
/// A panel with 2 sub panels and a mouse-draggable divider which allows
|
||||
/// apportioning the panel's width.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// html! {
|
||||
/// <SplitPanel id="app_panel">
|
||||
/// <div id="A">
|
||||
/// <div id="B">
|
||||
/// <a href=".."></a>
|
||||
/// </div>
|
||||
/// </SplitPanel>
|
||||
/// }
|
||||
/// ```
|
||||
pub struct SplitPanel {
|
||||
resize_state: Option<ResizingState>,
|
||||
refs: Vec<NodeRef>,
|
||||
styles: Vec<Option<String>>,
|
||||
on_reset: Option<Callback<()>>,
|
||||
}
|
||||
|
||||
impl Component for SplitPanel {
|
||||
type Message = SplitPanelMsg;
|
||||
type Properties = SplitPanelProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
assert!(ctx.props().validate());
|
||||
let len = ctx.props().children.len();
|
||||
// cant just use vec![Default::default(); len] as it would
|
||||
// use the same underlying NodeRef for each element.
|
||||
let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
|
||||
|
||||
let mut styles = vec![Default::default(); len];
|
||||
if let Some(x) = &ctx.props().initial_size {
|
||||
styles[0] = Some(match ctx.props().orientation {
|
||||
Orientation::Horizontal => {
|
||||
format!("max-width:{x}px;min-width:{x}px;width:{x}px")
|
||||
},
|
||||
Orientation::Vertical => {
|
||||
format!("max-height:{x}px;min-height:{x}px;height:{x}px")
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
resize_state: None,
|
||||
refs,
|
||||
styles,
|
||||
on_reset: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
SplitPanelMsg::Reset(index) => {
|
||||
self.styles[index] = None;
|
||||
self.on_reset.clone_from(&ctx.props().on_reset);
|
||||
},
|
||||
SplitPanelMsg::StartResizing(index, client_offset, pointer_id, pointer_elem) => {
|
||||
let elem = self.refs[index].cast::<HtmlElement>().unwrap();
|
||||
let state =
|
||||
ResizingState::new(index, client_offset, ctx, &elem, pointer_id, pointer_elem);
|
||||
|
||||
self.resize_state = state.ok();
|
||||
},
|
||||
SplitPanelMsg::StopResizing => {
|
||||
self.resize_state = None;
|
||||
if let Some(cb) = &ctx.props().on_resize_finished {
|
||||
cb.emit(());
|
||||
}
|
||||
},
|
||||
SplitPanelMsg::MoveResizing(client_offset) => {
|
||||
if let Some(state) = self.resize_state.as_ref() {
|
||||
if let Some(ref cb) = ctx.props().on_resize {
|
||||
cb.emit(state.get_dimensions(client_offset));
|
||||
}
|
||||
|
||||
self.styles[state.index] = state.get_style(client_offset);
|
||||
}
|
||||
},
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
|
||||
if let Some(on_reset) = self.on_reset.take() {
|
||||
on_reset.emit(());
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
assert!(ctx.props().validate());
|
||||
let new_len = ctx.props().children.len();
|
||||
self.refs.resize_with(new_len, Default::default);
|
||||
self.styles.resize(new_len, Default::default());
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let iter = ctx
|
||||
.props()
|
||||
.children
|
||||
.iter()
|
||||
.filter(|x| !ctx.props().skip_empty || x != &html! { <></> })
|
||||
.enumerate()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let orientation = ctx.props().orientation;
|
||||
let mut classes = classes!("split-panel");
|
||||
if orientation == Orientation::Vertical {
|
||||
classes.push("orient-vertical");
|
||||
}
|
||||
|
||||
if ctx.props().reverse {
|
||||
classes.push("orient-reverse");
|
||||
}
|
||||
|
||||
let count = iter.len();
|
||||
let contents = html! {
|
||||
<>
|
||||
for (i, x) in iter {
|
||||
if i == 0 {
|
||||
if count == 1 {
|
||||
<key=0>
|
||||
{x}
|
||||
</>
|
||||
} else {
|
||||
<SplitPanelChild
|
||||
key=0
|
||||
style={self.styles[i].clone()}
|
||||
ref_={self.refs[i].clone()}
|
||||
>
|
||||
{ x }
|
||||
</SplitPanelChild>
|
||||
}
|
||||
} else if i == count - 1 {
|
||||
<key={i}>
|
||||
<SplitPanelDivider
|
||||
i={i - 1}
|
||||
orientation={ctx.props().orientation}
|
||||
link={ctx.link().clone()}
|
||||
/>
|
||||
{ x }
|
||||
</>
|
||||
} else {
|
||||
<key={i}>
|
||||
<SplitPanelDivider
|
||||
i={i - 1}
|
||||
orientation={ctx.props().orientation}
|
||||
link={ctx.link().clone()}
|
||||
/>
|
||||
<SplitPanelChild
|
||||
style={self.styles[i].clone()}
|
||||
ref_={self.refs[i].clone()}
|
||||
>
|
||||
{ x }
|
||||
</SplitPanelChild>
|
||||
</>
|
||||
}
|
||||
}
|
||||
</>
|
||||
};
|
||||
|
||||
// TODO consider removing this
|
||||
if ctx.props().no_wrap {
|
||||
html! { { contents } }
|
||||
} else {
|
||||
html! { <div id={ctx.props().id.clone()} class={classes}>{ contents }</div> }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Eq, PartialEq)]
|
||||
pub enum Orientation {
|
||||
#[default]
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
#[derive(Properties)]
|
||||
struct SplitPanelDividerProps {
|
||||
i: usize,
|
||||
orientation: Orientation,
|
||||
link: Scope<SplitPanel>,
|
||||
}
|
||||
|
||||
impl PartialEq for SplitPanelDividerProps {
|
||||
fn eq(&self, rhs: &Self) -> bool {
|
||||
self.i == rhs.i && self.orientation == rhs.orientation
|
||||
}
|
||||
}
|
||||
|
||||
/// The resize handle for a `SplitPanel`.
|
||||
#[function_component(SplitPanelDivider)]
|
||||
fn split_panel_divider(props: &SplitPanelDividerProps) -> Html {
|
||||
let orientation = props.orientation;
|
||||
let i = props.i;
|
||||
let link = props.link.clone();
|
||||
let onmousedown = link.callback(move |event: PointerEvent| {
|
||||
let target = event.target().unwrap().unchecked_into::<HtmlElement>();
|
||||
let pointer_id = event.pointer_id();
|
||||
let size = match orientation {
|
||||
Orientation::Horizontal => event.client_x(),
|
||||
Orientation::Vertical => event.client_y(),
|
||||
};
|
||||
|
||||
SplitPanelMsg::StartResizing(i, size, pointer_id, target)
|
||||
});
|
||||
|
||||
let ondblclick = props.link.callback(move |event: MouseEvent| {
|
||||
event.prevent_default();
|
||||
event.stop_propagation();
|
||||
SplitPanelMsg::Reset(i)
|
||||
});
|
||||
|
||||
// TODO Not sure why, but under some circumstances this can trigger a
|
||||
// `dragstart`, leading to further drag events which cause perspective
|
||||
// havoc. `event.prevent_default()` in `onmousedown` alternatively fixes
|
||||
// this, but also prevents this event from trigger focus-stealing e.g. from
|
||||
// open dialogs.
|
||||
let ondragstart = Callback::from(|event: DragEvent| event.prevent_default());
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div
|
||||
class="split-panel-divider"
|
||||
{ondragstart}
|
||||
onpointerdown={onmousedown}
|
||||
{ondblclick}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct SplitPanelChildProps {
|
||||
style: Option<String>,
|
||||
ref_: NodeRef,
|
||||
children: Children,
|
||||
}
|
||||
|
||||
#[function_component(SplitPanelChild)]
|
||||
fn split_panel_child(props: &SplitPanelChildProps) -> Html {
|
||||
let class = if props.style.is_some() {
|
||||
classes!("split-panel-child", "is-width-override")
|
||||
} else {
|
||||
classes!("split-panel-child")
|
||||
};
|
||||
html! {
|
||||
<div {class} ref={props.ref_.clone()} style={props.style.clone()}>
|
||||
{ props.children.iter().next().unwrap() }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// The state for the `Resizing` action, including the `MouseEvent` callbacks
|
||||
/// and panel starting dimensions.
|
||||
struct ResizingState {
|
||||
mousemove: Closure<dyn Fn(MouseEvent)>,
|
||||
mouseup: Closure<dyn Fn(MouseEvent)>,
|
||||
cursor: String,
|
||||
index: usize,
|
||||
start: i32,
|
||||
total: i32,
|
||||
alt: i32,
|
||||
orientation: Orientation,
|
||||
reverse: bool,
|
||||
body_style: web_sys::CssStyleDeclaration,
|
||||
pointer_id: i32,
|
||||
pointer_elem: HtmlElement,
|
||||
}
|
||||
|
||||
impl Drop for ResizingState {
|
||||
/// On `drop`, we must remove these event listeners from the document
|
||||
/// `body`. Without this, the `Closure` objects would not leak, but the
|
||||
/// document will continue to call them, causing runtime exceptions.
|
||||
fn drop(&mut self) {
|
||||
let result: ApiResult<()> = (|| {
|
||||
let mousemove = self.mousemove.as_ref().unchecked_ref();
|
||||
global::body().remove_event_listener_with_callback("mousemove", mousemove)?;
|
||||
let mouseup = self.mouseup.as_ref().unchecked_ref();
|
||||
global::body().remove_event_listener_with_callback("mouseup", mouseup)?;
|
||||
self.release_cursor()?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
result.expect("Drop failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimum size a split panel child can be, including when overridden via
|
||||
/// user drag/drop.
|
||||
const MINIMUM_SIZE: i32 = 8;
|
||||
|
||||
/// When the instantiated, capture the initial dimensions and create the
|
||||
/// MouseEvent callbacks.
|
||||
impl ResizingState {
|
||||
pub fn new(
|
||||
index: usize,
|
||||
client_offset: i32,
|
||||
ctx: &Context<SplitPanel>,
|
||||
first_elem: &HtmlElement,
|
||||
pointer_id: i32,
|
||||
pointer_elem: HtmlElement,
|
||||
) -> ApiResult<Self> {
|
||||
let orientation = ctx.props().orientation;
|
||||
let reverse = ctx.props().reverse;
|
||||
let split_panel = ctx.link();
|
||||
let total = match orientation {
|
||||
Orientation::Horizontal => first_elem.offset_width(),
|
||||
Orientation::Vertical => first_elem.offset_height(),
|
||||
};
|
||||
|
||||
let alt = match orientation {
|
||||
Orientation::Horizontal => first_elem.offset_height(),
|
||||
Orientation::Vertical => first_elem.offset_width(),
|
||||
};
|
||||
|
||||
let mouseup = Closure::new({
|
||||
let cb = split_panel.callback(|_| SplitPanelMsg::StopResizing);
|
||||
move |x| cb.emit(x)
|
||||
});
|
||||
|
||||
let mousemove = Closure::new({
|
||||
let cb = split_panel.callback(move |event: MouseEvent| {
|
||||
SplitPanelMsg::MoveResizing(match orientation {
|
||||
Orientation::Horizontal => event.client_x(),
|
||||
Orientation::Vertical => event.client_y(),
|
||||
})
|
||||
});
|
||||
|
||||
move |x| cb.emit(x)
|
||||
});
|
||||
|
||||
let mut state = Self {
|
||||
index,
|
||||
cursor: "".to_owned(),
|
||||
start: client_offset,
|
||||
orientation,
|
||||
reverse,
|
||||
total,
|
||||
alt,
|
||||
body_style: global::body().style(),
|
||||
mouseup,
|
||||
mousemove,
|
||||
pointer_id,
|
||||
pointer_elem,
|
||||
};
|
||||
|
||||
state.capture_cursor()?;
|
||||
state.register_listeners()?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn get_offset(&self, client_offset: i32) -> i32 {
|
||||
let delta = if self.reverse {
|
||||
self.start - client_offset
|
||||
} else {
|
||||
client_offset - self.start
|
||||
};
|
||||
|
||||
max(MINIMUM_SIZE, self.total + delta)
|
||||
}
|
||||
|
||||
pub fn get_style(&self, client_offset: i32) -> Option<String> {
|
||||
let offset = self.get_offset(client_offset);
|
||||
Some(match self.orientation {
|
||||
Orientation::Horizontal => {
|
||||
format!("max-width:{offset}px;min-width:{offset}px;width:{offset}px")
|
||||
},
|
||||
Orientation::Vertical => {
|
||||
format!("max-height:{offset}px;min-height:{offset}px;height:{offset}px")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_dimensions(&self, client_offset: i32) -> (i32, i32) {
|
||||
let offset = self.get_offset(client_offset);
|
||||
match self.orientation {
|
||||
Orientation::Horizontal => (std::cmp::max(MINIMUM_SIZE, offset), self.alt),
|
||||
Orientation::Vertical => (self.alt, std::cmp::max(MINIMUM_SIZE, offset)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the event listeners, the corollary of `Drop`.
|
||||
fn register_listeners(&self) -> ApiResult<()> {
|
||||
let mousemove = self.mousemove.as_ref().unchecked_ref();
|
||||
global::body().add_event_listener_with_callback("mousemove", mousemove)?;
|
||||
let mouseup = self.mouseup.as_ref().unchecked_ref();
|
||||
Ok(global::body().add_event_listener_with_callback("mouseup", mouseup)?)
|
||||
}
|
||||
|
||||
/// Helper functions capture and release the global cursor while dragging is
|
||||
/// occurring.
|
||||
fn capture_cursor(&mut self) -> ApiResult<()> {
|
||||
self.pointer_elem.set_pointer_capture(self.pointer_id)?;
|
||||
self.cursor = self.body_style.get_property_value("cursor")?;
|
||||
self.body_style
|
||||
.set_property("cursor", match self.orientation {
|
||||
Orientation::Horizontal => "col-resize",
|
||||
Orientation::Vertical => "row-resize",
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// " but for release
|
||||
fn release_cursor(&self) -> ApiResult<()> {
|
||||
self.pointer_elem.release_pointer_capture(self.pointer_id)?;
|
||||
Ok(self.body_style.set_property("cursor", &self.cursor)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::{Callback, Children, Component, Html, Properties, classes, html};
|
||||
|
||||
use crate::presentation::ColumnTab;
|
||||
|
||||
#[derive(Properties, Debug, PartialEq)]
|
||||
pub struct TabListProps<T: ColumnTab> {
|
||||
/// All tabs
|
||||
pub tabs: Vec<T>,
|
||||
|
||||
/// Fires when the selected tab changes in the UI.
|
||||
pub on_tab_change: Callback<(usize, T)>,
|
||||
|
||||
// Which tab is selected.
|
||||
pub selected_tab: Option<usize>,
|
||||
|
||||
// The currently instantiated tabs.
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
pub enum TabListMsg {
|
||||
SetSelected(usize),
|
||||
}
|
||||
|
||||
pub struct TabList<T: ColumnTab> {
|
||||
t: std::marker::PhantomData<T>,
|
||||
selected_idx: usize,
|
||||
}
|
||||
|
||||
impl<T: ColumnTab> Component for TabList<T> {
|
||||
type Message = TabListMsg;
|
||||
type Properties = TabListProps<T>;
|
||||
|
||||
fn create(_ctx: &yew::Context<Self>) -> Self {
|
||||
Self {
|
||||
t: std::marker::PhantomData,
|
||||
selected_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
TabListMsg::SetSelected(idx) => {
|
||||
ctx.props()
|
||||
.on_tab_change
|
||||
.emit((idx, ctx.props().tabs[idx].clone()));
|
||||
self.selected_idx = idx;
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &yew::Context<Self>, _old_props: &Self::Properties) -> bool {
|
||||
self.selected_idx = ctx.props().selected_tab.unwrap_or_default();
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &yew::Context<Self>) -> Html {
|
||||
let p = ctx.props();
|
||||
let gutter_tabs = p.tabs.iter().enumerate().map(|(idx, tab)| {
|
||||
let mut class = classes!("settings_tab");
|
||||
if idx == self.selected_idx {
|
||||
class.push("selected_tab");
|
||||
}
|
||||
|
||||
let onclick = ctx.link().callback(move |_| TabListMsg::SetSelected(idx));
|
||||
html! {
|
||||
<span {class} {onclick}>
|
||||
<div class="tab-title" id={tab.to_string()} />
|
||||
<div class="tab-border" />
|
||||
</span>
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div id="settings_tab_bar">{ for gutter_tabs }</div>
|
||||
<div id="format-tab" class="tab-content scrollable">
|
||||
{ ctx.props().children.iter().nth(self.selected_idx) }
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::containers::dropdown_menu::*;
|
||||
use crate::config::*;
|
||||
use crate::renderer::*;
|
||||
|
||||
type CopyDropDownMenuItem = DropDownMenuItem<ExportFile>;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CopyDropDownMenuProps {
|
||||
pub callback: Callback<ExportFile>,
|
||||
pub renderer: Renderer,
|
||||
}
|
||||
|
||||
pub struct CopyDropDownMenu {}
|
||||
|
||||
impl Component for CopyDropDownMenu {
|
||||
type Message = ();
|
||||
type Properties = CopyDropDownMenuProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, _msg: Self::Message) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> yew::virtual_dom::VNode {
|
||||
let is_chart = ctx.props().renderer.is_chart();
|
||||
let has_selection = ctx.props().renderer.get_selection().is_some();
|
||||
html! {
|
||||
<>
|
||||
<div id="test" />
|
||||
<DropDownMenu<ExportFile>
|
||||
values={Rc::new(get_menu_items(is_chart, has_selection))}
|
||||
callback={&ctx.props().callback}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_menu_items(is_chart: bool, has_selection: bool) -> Vec<CopyDropDownMenuItem> {
|
||||
let mut items = vec![
|
||||
CopyDropDownMenuItem::OptGroup(
|
||||
"Current View".into(),
|
||||
if is_chart {
|
||||
vec![
|
||||
ExportMethod::Csv.new_file("clipboard", is_chart),
|
||||
ExportMethod::Json.new_file("clipboard", is_chart),
|
||||
ExportMethod::Ndjson.new_file("clipboard", is_chart),
|
||||
ExportMethod::Plugin.new_file("clipboard", is_chart),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ExportMethod::Csv.new_file("clipboard", is_chart),
|
||||
ExportMethod::Json.new_file("clipboard", is_chart),
|
||||
ExportMethod::Ndjson.new_file("clipboard", is_chart),
|
||||
]
|
||||
},
|
||||
),
|
||||
CopyDropDownMenuItem::OptGroup("All".into(), vec![
|
||||
ExportMethod::CsvAll.new_file("clipboard", is_chart),
|
||||
ExportMethod::JsonAll.new_file("clipboard", is_chart),
|
||||
ExportMethod::NdjsonAll.new_file("clipboard", is_chart),
|
||||
]),
|
||||
CopyDropDownMenuItem::OptGroup("Config".into(), vec![
|
||||
ExportMethod::JsonConfig.new_file("clipboard", is_chart),
|
||||
]),
|
||||
];
|
||||
|
||||
if has_selection {
|
||||
items.insert(
|
||||
0,
|
||||
CopyDropDownMenuItem::OptGroup(
|
||||
"Current Selection".into(),
|
||||
if is_chart {
|
||||
vec![
|
||||
ExportMethod::CsvSelected.new_file("clipboard", is_chart),
|
||||
ExportMethod::JsonSelected.new_file("clipboard", is_chart),
|
||||
ExportMethod::NdjsonSelected.new_file("clipboard", is_chart),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ExportMethod::CsvSelected.new_file("clipboard", is_chart),
|
||||
ExportMethod::JsonSelected.new_file("clipboard", is_chart),
|
||||
ExportMethod::NdjsonSelected.new_file("clipboard", is_chart),
|
||||
ExportMethod::Plugin.new_file("clipboard", is_chart),
|
||||
]
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
mod custom;
|
||||
mod simple;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use derivative::Derivative;
|
||||
use perspective_js::json;
|
||||
use perspective_js::utils::global::navigator;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::modal::{ModalLink, SetModalLink};
|
||||
use crate::components::datetime_column_style::custom::DatetimeStyleCustom;
|
||||
use crate::components::datetime_column_style::simple::DatetimeStyleSimple;
|
||||
use crate::components::form::select_value_field::SelectValueField;
|
||||
use crate::config::*;
|
||||
use crate::utils::WeakScope;
|
||||
|
||||
/// Format-only widget for `datetime` columns. Renders the `date_format`
|
||||
/// hierarchy (Simple|Custom + timezone); color/color-mode UI is provided
|
||||
/// externally as primitive `Enum` + `Color` schema fields.
|
||||
#[derive(Properties, Derivative)]
|
||||
#[derivative(Debug)]
|
||||
pub struct DatetimeColumnStyleProps {
|
||||
pub enable_time_config: bool,
|
||||
pub config: Option<DatetimeColumnStyleConfig>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_change: Callback<ColumnConfigFieldUpdate>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub keys: Vec<String>,
|
||||
|
||||
#[prop_or_default]
|
||||
#[derivative(Debug = "ignore")]
|
||||
weak_link: WeakScope<DatetimeColumnStyle>,
|
||||
}
|
||||
|
||||
impl ModalLink<DatetimeColumnStyle> for DatetimeColumnStyleProps {
|
||||
fn weak_link(&self) -> &'_ WeakScope<DatetimeColumnStyle> {
|
||||
&self.weak_link
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for DatetimeColumnStyleProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.enable_time_config == other.enable_time_config && self.config == other.config
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DatetimeColumnStyleMsg {
|
||||
SimpleDatetimeStyleConfigChanged(SimpleDatetimeStyleConfig),
|
||||
CustomDatetimeStyleConfigChanged(CustomDatetimeStyleConfig),
|
||||
TimezoneChanged(Option<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DatetimeColumnStyle {
|
||||
config: DatetimeColumnStyleConfig,
|
||||
}
|
||||
|
||||
impl Component for DatetimeColumnStyle {
|
||||
type Message = DatetimeColumnStyleMsg;
|
||||
type Properties = DatetimeColumnStyleProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
ctx.set_modal_link();
|
||||
Self {
|
||||
config: ctx.props().config.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, old: &Self::Properties) -> bool {
|
||||
let mut rerender = false;
|
||||
let mut new_config = ctx.props().config.clone().unwrap_or_default();
|
||||
if self.config != new_config {
|
||||
std::mem::swap(&mut self.config, &mut new_config);
|
||||
rerender = true;
|
||||
}
|
||||
if old.enable_time_config != ctx.props().enable_time_config {
|
||||
rerender = true;
|
||||
}
|
||||
rerender
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
DatetimeColumnStyleMsg::TimezoneChanged(val) => {
|
||||
if Some(&*USER_TIMEZONE) != val.as_ref() {
|
||||
*self.config.date_format.time_zone_mut() = val;
|
||||
} else {
|
||||
*self.config.date_format.time_zone_mut() = None;
|
||||
}
|
||||
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged(simple) => {
|
||||
self.config.date_format = DatetimeFormatType::Simple(simple);
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeColumnStyleMsg::CustomDatetimeStyleConfigChanged(custom) => {
|
||||
self.config.date_format = DatetimeFormatType::Custom(custom);
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<div id="column-style-container" class="datetime-column-style-container">
|
||||
if ctx.props().enable_time_config {
|
||||
<SelectValueField<String>
|
||||
label="timezone"
|
||||
values={ALL_TIMEZONES.with(|x| (*x).clone())}
|
||||
default_value={(*USER_TIMEZONE).clone()}
|
||||
on_change={ctx.link().callback(DatetimeColumnStyleMsg::TimezoneChanged)}
|
||||
current_value={self.config.date_format.time_zone().as_ref().unwrap_or(&*USER_TIMEZONE).clone()}
|
||||
/>
|
||||
}
|
||||
if let DatetimeFormatType::Simple(config) = &self.config.date_format {
|
||||
if ctx.props().enable_time_config {
|
||||
<div class="row">
|
||||
<button
|
||||
id="datetime_format"
|
||||
data-title="Simple"
|
||||
data-title-hover="Switch to Custom"
|
||||
onclick={ctx.link().callback(|_| DatetimeColumnStyleMsg::CustomDatetimeStyleConfigChanged(CustomDatetimeStyleConfig::default()))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<DatetimeStyleSimple
|
||||
enable_time_config={ctx.props().enable_time_config}
|
||||
on_change={ctx.link().callback(DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged)}
|
||||
config={config.clone()}
|
||||
/>
|
||||
} else if let DatetimeFormatType::Custom(config) = &self.config.date_format {
|
||||
if ctx.props().enable_time_config {
|
||||
<div class="row">
|
||||
<button
|
||||
id="datetime_format"
|
||||
data-title="Custom"
|
||||
data-title-hover="Switch to Simple"
|
||||
onclick={ctx.link().callback(|_| DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged(SimpleDatetimeStyleConfig::default()))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<DatetimeStyleCustom
|
||||
enable_time_config={ctx.props().enable_time_config}
|
||||
on_change={ctx.link().callback(DatetimeColumnStyleMsg::CustomDatetimeStyleConfigChanged)}
|
||||
config={config.clone()}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = supportedValuesOf, js_namespace = Intl)]
|
||||
pub fn supported_values_of(s: &JsValue) -> js_sys::Array;
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ALL_TIMEZONES: LazyLock<Rc<Vec<String>>> = LazyLock::new(|| {
|
||||
Rc::new(
|
||||
supported_values_of(&JsValue::from("timeZone"))
|
||||
.iter()
|
||||
.map(|x| x.as_string().unwrap())
|
||||
.collect(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
static USER_TIMEZONE: LazyLock<String> = LazyLock::new(|| {
|
||||
js_sys::Reflect::get(
|
||||
&js_sys::Intl::DateTimeFormat::new(&navigator().languages(), &json!({})).resolved_options(),
|
||||
&JsValue::from("timeZone"),
|
||||
)
|
||||
.unwrap()
|
||||
.as_string()
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
impl DatetimeColumnStyle {
|
||||
/// When this config has changed, we must signal the wrapper element.
|
||||
fn dispatch_config(&self, ctx: &Context<Self>) {
|
||||
let value = if self.config == DatetimeColumnStyleConfig::default() {
|
||||
serde_json::Map::new()
|
||||
} else {
|
||||
match serde_json::to_value(&self.config) {
|
||||
Ok(serde_json::Value::Object(m)) => m,
|
||||
_ => serde_json::Map::new(),
|
||||
}
|
||||
};
|
||||
|
||||
ctx.props().on_change.emit(ColumnConfigFieldUpdate {
|
||||
keys: ctx.props().keys.clone(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::form::number_field::NumberField;
|
||||
use crate::components::form::select_value_field::SelectValueField;
|
||||
use crate::components::modal::{ModalLink, SetModalLink};
|
||||
use crate::config::*;
|
||||
use crate::utils::WeakScope;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct DatetimeStyleCustomProps {
|
||||
pub enable_time_config: bool,
|
||||
|
||||
pub config: CustomDatetimeStyleConfig,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_change: Callback<CustomDatetimeStyleConfig>,
|
||||
|
||||
#[prop_or_default]
|
||||
weak_link: WeakScope<DatetimeStyleCustom>,
|
||||
}
|
||||
|
||||
impl ModalLink<DatetimeStyleCustom> for DatetimeStyleCustomProps {
|
||||
fn weak_link(&self) -> &'_ WeakScope<DatetimeStyleCustom> {
|
||||
&self.weak_link
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for DatetimeStyleCustomProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.enable_time_config == other.enable_time_config && self.config == other.config
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DatetimeStyleCustomMsg {
|
||||
FractionalSeconds(Option<f64>),
|
||||
Year(CustomDatetimeFormat),
|
||||
Month(CustomDatetimeFormat),
|
||||
Day(CustomDatetimeFormat),
|
||||
Weekday(CustomDatetimeFormat),
|
||||
Hour(CustomDatetimeFormat),
|
||||
Minute(CustomDatetimeFormat),
|
||||
Second(CustomDatetimeFormat),
|
||||
Hour12(bool),
|
||||
}
|
||||
|
||||
/// The custom variation of the options parameter for `Intl.DatetimeFormat`.
|
||||
/// Complement to `DatetimeStyleSimple`.
|
||||
pub struct DatetimeStyleCustom {
|
||||
config: CustomDatetimeStyleConfig,
|
||||
}
|
||||
|
||||
impl Component for DatetimeStyleCustom {
|
||||
type Message = DatetimeStyleCustomMsg;
|
||||
type Properties = DatetimeStyleCustomProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
ctx.set_modal_link();
|
||||
Self {
|
||||
config: ctx.props().config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// Always re-render when config changes.
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
let mut new_config = ctx.props().config.clone();
|
||||
if self.config != new_config {
|
||||
std::mem::swap(&mut self.config, &mut new_config);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// TODO could be more conservative here with re-rendering
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
DatetimeStyleCustomMsg::Year(year) => {
|
||||
self.config.year = year;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleCustomMsg::Month(month) => {
|
||||
self.config.month = month;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleCustomMsg::Day(day) => {
|
||||
self.config.day = day;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleCustomMsg::Weekday(weekday) => {
|
||||
self.config.weekday = weekday;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleCustomMsg::Hour(hour) => {
|
||||
self.config.hour = hour;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleCustomMsg::Minute(minute) => {
|
||||
self.config.minute = minute;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleCustomMsg::Second(second) => {
|
||||
self.config.second = second;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleCustomMsg::FractionalSeconds(fractional) => {
|
||||
self.config.fractional_seconds = fractional.unwrap_or_default().floor() as u32;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleCustomMsg::Hour12(hour12) => {
|
||||
self.config.hour12 = hour12;
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let all_values = Rc::new(Vec::from(CustomDatetimeFormat::values()));
|
||||
let number_values = Rc::new(Vec::from(CustomDatetimeFormat::values_1()));
|
||||
let text_values = Rc::new(Vec::from(CustomDatetimeFormat::values_2()));
|
||||
type SelectStrField = SelectValueField<&'static str>;
|
||||
html! {
|
||||
<>
|
||||
if ctx.props().enable_time_config {
|
||||
<SelectValueField<CustomDatetimeFormat>
|
||||
label="year"
|
||||
values={number_values.clone()}
|
||||
default_value={CustomDatetimeFormat::TwoDigit}
|
||||
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Year(x.unwrap_or(CustomDatetimeFormat::TwoDigit)))}
|
||||
current_value={self.config.year}
|
||||
/>
|
||||
<SelectValueField<CustomDatetimeFormat>
|
||||
label="month"
|
||||
values={all_values.clone()}
|
||||
default_value={CustomDatetimeFormat::Numeric}
|
||||
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Month(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
|
||||
current_value={self.config.month}
|
||||
/>
|
||||
<SelectValueField<CustomDatetimeFormat>
|
||||
label="day"
|
||||
values={number_values.clone()}
|
||||
default_value={CustomDatetimeFormat::Numeric}
|
||||
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Day(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
|
||||
current_value={self.config.day}
|
||||
/>
|
||||
<SelectValueField<CustomDatetimeFormat>
|
||||
label="weekday"
|
||||
values={text_values.clone()}
|
||||
default_value={CustomDatetimeFormat::Disabled}
|
||||
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Weekday(x.unwrap_or(CustomDatetimeFormat::Disabled)))}
|
||||
current_value={self.config.weekday}
|
||||
/>
|
||||
<SelectValueField<CustomDatetimeFormat>
|
||||
label="hour"
|
||||
values={number_values.clone()}
|
||||
default_value={CustomDatetimeFormat::Numeric}
|
||||
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Hour(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
|
||||
current_value={self.config.hour}
|
||||
/>
|
||||
<SelectValueField<CustomDatetimeFormat>
|
||||
label="minute"
|
||||
values={number_values.clone()}
|
||||
default_value={CustomDatetimeFormat::Numeric}
|
||||
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Minute(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
|
||||
current_value={self.config.minute}
|
||||
/>
|
||||
<SelectValueField<CustomDatetimeFormat>
|
||||
label="second"
|
||||
values={number_values.clone()}
|
||||
default_value={CustomDatetimeFormat::Numeric}
|
||||
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Second(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
|
||||
current_value={self.config.second}
|
||||
/>
|
||||
<NumberField
|
||||
label="fractional-seconds"
|
||||
min=0.
|
||||
max=3.
|
||||
step=1.
|
||||
default=0.
|
||||
current_value={self.config.fractional_seconds as f64}
|
||||
on_change={ctx.link().callback(DatetimeStyleCustomMsg::FractionalSeconds)}
|
||||
/>
|
||||
<SelectStrField
|
||||
label="hours"
|
||||
values={Rc::new(vec!["12 Hour", "24 Hour"])}
|
||||
default_value="12 Hour"
|
||||
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Hour12(x != Some("24 Hour") ))}
|
||||
current_value={if self.config.hour12 { "12 Hour" } else { "24 Hour" }}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatetimeStyleCustom {
|
||||
fn dispatch_config(&self, ctx: &Context<Self>) {
|
||||
ctx.props().on_change.emit(self.config.clone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::form::select_enum_field::SelectEnumField;
|
||||
use crate::components::modal::{ModalLink, SetModalLink};
|
||||
use crate::config::*;
|
||||
use crate::utils::WeakScope;
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct DatetimeStyleSimpleProps {
|
||||
pub enable_time_config: bool,
|
||||
pub config: SimpleDatetimeStyleConfig,
|
||||
|
||||
#[prop_or_default]
|
||||
pub on_change: Callback<SimpleDatetimeStyleConfig>,
|
||||
|
||||
#[prop_or_default]
|
||||
weak_link: WeakScope<DatetimeStyleSimple>,
|
||||
}
|
||||
|
||||
impl ModalLink<DatetimeStyleSimple> for DatetimeStyleSimpleProps {
|
||||
fn weak_link(&self) -> &'_ WeakScope<DatetimeStyleSimple> {
|
||||
&self.weak_link
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for DatetimeStyleSimpleProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.enable_time_config == other.enable_time_config && self.config == other.config
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DatetimeStyleSimpleMsg {
|
||||
DateStyleChanged(Option<SimpleDatetimeFormat>),
|
||||
TimeStyleChanged(Option<SimpleDatetimeFormat>),
|
||||
}
|
||||
|
||||
/// `DatetimeStyleSimple` represents the variation of the options parameter to
|
||||
/// `Intl.DatetimeFormat()` which supports `timeStyle` and `dateStyle`. These
|
||||
/// options are mutually exclusive with those of `DatetimeStyleCustom`, hence
|
||||
/// the two-struct model for this options parameter.
|
||||
pub struct DatetimeStyleSimple {
|
||||
config: SimpleDatetimeStyleConfig,
|
||||
}
|
||||
|
||||
impl Component for DatetimeStyleSimple {
|
||||
type Message = DatetimeStyleSimpleMsg;
|
||||
type Properties = DatetimeStyleSimpleProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
ctx.set_modal_link();
|
||||
Self {
|
||||
config: ctx.props().config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO could be more conservative here with re-rendering
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
DatetimeStyleSimpleMsg::DateStyleChanged(format) => {
|
||||
self.config.date_style = format.unwrap_or_default();
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
DatetimeStyleSimpleMsg::TimeStyleChanged(format) => {
|
||||
self.config.time_style = format.unwrap_or_default();
|
||||
self.dispatch_config(ctx);
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Always re-render when config changes.
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
let mut new_config = ctx.props().config.clone();
|
||||
if self.config != new_config {
|
||||
std::mem::swap(&mut self.config, &mut new_config);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<SelectEnumField<SimpleDatetimeFormat>
|
||||
label="date-style"
|
||||
on_change={ctx.link().callback(DatetimeStyleSimpleMsg::DateStyleChanged)}
|
||||
current_value={self.config.date_style}
|
||||
/>
|
||||
if ctx.props().enable_time_config {
|
||||
<SelectEnumField<SimpleDatetimeFormat>
|
||||
label="time-style"
|
||||
on_change={ctx.link().callback(DatetimeStyleSimpleMsg::TimeStyleChanged)}
|
||||
current_value={self.config.time_style}
|
||||
default_value={SimpleDatetimeFormat::Medium}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatetimeStyleSimple {
|
||||
/// When this config has changed, we must signal the wrapper element.
|
||||
fn dispatch_config(&self, ctx: &Context<Self>) {
|
||||
ctx.props().on_change.emit(self.config.clone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use web_sys::{FocusEvent, HtmlInputElement, KeyboardEvent};
|
||||
use yew::{Callback, Component, Html, NodeRef, Properties, TargetCast, classes, html};
|
||||
|
||||
use super::type_icon::TypeIconType;
|
||||
use crate::components::type_icon::TypeIcon;
|
||||
use crate::session::{Session, SessionMetadataRc};
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct EditableHeaderProps {
|
||||
pub icon_type: Option<TypeIconType>,
|
||||
pub on_change: Callback<(Option<String>, bool)>,
|
||||
pub editable: bool,
|
||||
pub initial_value: Option<String>,
|
||||
pub placeholder: Rc<String>,
|
||||
|
||||
// TODO remove this pattern
|
||||
#[prop_or_default]
|
||||
pub reset_count: u8,
|
||||
|
||||
#[prop_or_default]
|
||||
pub update_on_input: bool,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
// State
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
impl EditableHeaderProps {
|
||||
fn split_placeholder(&self) -> String {
|
||||
let split = self
|
||||
.placeholder
|
||||
.split_once('\n')
|
||||
.map(|(a, _)| a)
|
||||
.unwrap_or(&*self.placeholder);
|
||||
|
||||
match split.char_indices().nth(25) {
|
||||
None => split.to_string(),
|
||||
Some((idx, _)) => split[..idx].to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum EditableHeaderMsg {
|
||||
SetNewValue(String),
|
||||
OnClick(()),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EditableHeader {
|
||||
noderef: NodeRef,
|
||||
edited: bool,
|
||||
valid: bool,
|
||||
value: Option<String>,
|
||||
placeholder: String,
|
||||
}
|
||||
|
||||
impl Component for EditableHeader {
|
||||
type Message = EditableHeaderMsg;
|
||||
type Properties = EditableHeaderProps;
|
||||
|
||||
fn create(ctx: &yew::prelude::Context<Self>) -> Self {
|
||||
Self {
|
||||
value: ctx.props().initial_value.clone(),
|
||||
placeholder: ctx.props().split_placeholder(),
|
||||
valid: true,
|
||||
noderef: NodeRef::default(),
|
||||
edited: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &yew::prelude::Context<Self>, old_props: &Self::Properties) -> bool {
|
||||
if ctx.props().reset_count != old_props.reset_count {
|
||||
self.value.clone_from(&ctx.props().initial_value);
|
||||
}
|
||||
if ctx.props().initial_value != old_props.initial_value {
|
||||
self.edited = false;
|
||||
self.value.clone_from(&ctx.props().initial_value);
|
||||
}
|
||||
if !ctx.props().editable {
|
||||
self.edited = false;
|
||||
}
|
||||
self.placeholder = ctx.props().split_placeholder();
|
||||
ctx.props() != old_props
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &yew::prelude::Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
EditableHeaderMsg::SetNewValue(new_value) => {
|
||||
let maybe_value = (!new_value.is_empty()).then_some(new_value.clone());
|
||||
self.edited = ctx.props().initial_value != maybe_value;
|
||||
|
||||
self.valid = (|| -> Option<bool> {
|
||||
if maybe_value
|
||||
.as_ref()
|
||||
.map(|v| v == &self.placeholder)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return Some(true);
|
||||
}
|
||||
if !self.edited {
|
||||
return Some(true);
|
||||
}
|
||||
let metadata = &ctx.props().metadata;
|
||||
let expressions = metadata.get_expression_columns();
|
||||
let found = metadata
|
||||
.get_table_columns()?
|
||||
.iter()
|
||||
.chain(expressions)
|
||||
.contains(&new_value);
|
||||
Some(!found)
|
||||
})()
|
||||
.unwrap_or(true);
|
||||
|
||||
self.value.clone_from(&maybe_value);
|
||||
ctx.props().on_change.emit((maybe_value, self.valid));
|
||||
true
|
||||
},
|
||||
EditableHeaderMsg::OnClick(()) => {
|
||||
self.noderef
|
||||
.cast::<HtmlInputElement>()
|
||||
.unwrap()
|
||||
.focus()
|
||||
.unwrap();
|
||||
false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &yew::prelude::Context<Self>) -> Html {
|
||||
let mut classes = classes!("sidebar_header_contents");
|
||||
if ctx.props().editable {
|
||||
classes.push("editable");
|
||||
}
|
||||
|
||||
if !self.valid {
|
||||
classes.push("invalid");
|
||||
}
|
||||
|
||||
if self.edited {
|
||||
classes.push("edited");
|
||||
}
|
||||
|
||||
let onkeyup = ctx.link().callback(|e: KeyboardEvent| {
|
||||
let value = e.target_unchecked_into::<HtmlInputElement>().value();
|
||||
EditableHeaderMsg::SetNewValue(value)
|
||||
});
|
||||
|
||||
let onblur = ctx.link().callback(|e: FocusEvent| {
|
||||
let value = e.target_unchecked_into::<HtmlInputElement>().value();
|
||||
EditableHeaderMsg::SetNewValue(value)
|
||||
});
|
||||
|
||||
let update_on_input = ctx.props().update_on_input;
|
||||
let oninput = ctx.link().batch_callback(move |e: yew::InputEvent| {
|
||||
if update_on_input {
|
||||
let value = e.target_unchecked_into::<HtmlInputElement>().value();
|
||||
vec![EditableHeaderMsg::SetNewValue(value)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class={classes} onclick={ctx.link().callback(|_| EditableHeaderMsg::OnClick(()))}>
|
||||
if let Some(icon) = ctx.props().icon_type { <TypeIcon ty={icon} /> }
|
||||
<input
|
||||
ref={self.noderef.clone()}
|
||||
type="search"
|
||||
class="sidebar_header_title"
|
||||
disabled={!ctx.props().editable}
|
||||
{onblur}
|
||||
{onkeyup}
|
||||
{oninput}
|
||||
value={self.value.clone()}
|
||||
placeholder={self.placeholder.clone()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq, Copy, Clone)]
|
||||
pub enum ValueState {
|
||||
#[default]
|
||||
Unedited,
|
||||
Edited,
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use derivative::Derivative;
|
||||
use perspective_client::clone;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::filter_dropdown::FilterDropDownElement;
|
||||
|
||||
#[derive(Properties, Derivative)]
|
||||
#[derivative(Debug)]
|
||||
pub struct EmptyRowProps {
|
||||
#[derivative(Debug = "ignore")]
|
||||
pub dropdown: Rc<FilterDropDownElement>,
|
||||
pub exclude: HashSet<String>,
|
||||
pub on_select: Callback<String>,
|
||||
pub focused: bool,
|
||||
pub index: usize,
|
||||
pub set_focused_index: Callback<Option<usize>>,
|
||||
pub value: String,
|
||||
pub column_name: String,
|
||||
}
|
||||
|
||||
impl PartialEq for EmptyRowProps {
|
||||
fn eq(&self, _other: &Self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub enum EmptyRowMsg {
|
||||
KeyDown(u32),
|
||||
Blur,
|
||||
Input(String),
|
||||
Focus,
|
||||
}
|
||||
|
||||
use EmptyRowMsg::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EmptyRow {
|
||||
input_ref: NodeRef,
|
||||
}
|
||||
|
||||
impl Component for EmptyRow {
|
||||
type Message = EmptyRowMsg;
|
||||
type Properties = EmptyRowProps;
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let onblur = ctx.link().callback(|_| Blur);
|
||||
let oninput = ctx.link().callback(|e: InputEvent| {
|
||||
let value = e
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value();
|
||||
Input(value)
|
||||
});
|
||||
let onkeydown = ctx
|
||||
.link()
|
||||
.callback(|event: KeyboardEvent| KeyDown(event.key_code()));
|
||||
|
||||
if ctx.props().focused {
|
||||
// do this on the next render cycle so we know the ref is there
|
||||
ctx.link()
|
||||
.send_message_batch(vec![Focus, Input(ctx.props().value.clone())]);
|
||||
}
|
||||
let onfocus = {
|
||||
clone!(ctx.props().value);
|
||||
ctx.link().callback(move |_| Input(value.clone()))
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="pivot-column column-empty">
|
||||
<input
|
||||
spellcheck="false"
|
||||
ref={self.input_ref.clone()}
|
||||
{onblur}
|
||||
{onkeydown}
|
||||
{oninput}
|
||||
{onfocus}
|
||||
class="column-empty-input"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, _ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
|
||||
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
|
||||
elem.blur().unwrap();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn destroy(&mut self, ctx: &Context<Self>) {
|
||||
ctx.props().dropdown.hide().unwrap();
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
let p = ctx.props();
|
||||
match msg {
|
||||
Focus => {
|
||||
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
|
||||
elem.set_value(&ctx.props().value);
|
||||
elem.focus().unwrap();
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
Blur => {
|
||||
p.dropdown.hide().unwrap();
|
||||
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
|
||||
elem.set_value("");
|
||||
}
|
||||
p.set_focused_index.emit(Some(p.index + 1));
|
||||
|
||||
false
|
||||
},
|
||||
KeyDown(40) => {
|
||||
p.dropdown.item_down();
|
||||
false
|
||||
},
|
||||
KeyDown(38) => {
|
||||
p.dropdown.item_up();
|
||||
false
|
||||
},
|
||||
KeyDown(13) => {
|
||||
p.dropdown.item_select();
|
||||
p.dropdown.hide().unwrap();
|
||||
p.set_focused_index.emit(Some(p.index + 1));
|
||||
true
|
||||
},
|
||||
KeyDown(_) => false,
|
||||
Input(value) => {
|
||||
if let Some(elem) = self.input_ref.cast::<HtmlElement>() {
|
||||
ctx.props().dropdown.autocomplete(
|
||||
(ctx.props().index, ctx.props().column_name.clone()),
|
||||
value,
|
||||
ctx.props().exclude.clone(),
|
||||
elem,
|
||||
ctx.props().on_select.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::containers::dropdown_menu::*;
|
||||
use crate::config::*;
|
||||
use crate::renderer::*;
|
||||
use crate::session::Session;
|
||||
|
||||
pub type ExportDropDownMenuItem = DropDownMenuItem<ExportFile>;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ExportDropDownMenuProps {
|
||||
pub renderer: Renderer,
|
||||
pub session: Session,
|
||||
pub callback: Callback<ExportFile>,
|
||||
}
|
||||
|
||||
pub enum ExportDropDownMenuMsg {
|
||||
TitleChange,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ExportDropDownMenu {
|
||||
title: String,
|
||||
input_ref: NodeRef,
|
||||
invalid: bool,
|
||||
}
|
||||
|
||||
impl Component for ExportDropDownMenu {
|
||||
type Message = ExportDropDownMenuMsg;
|
||||
type Properties = ExportDropDownMenuProps;
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> yew::virtual_dom::VNode {
|
||||
let callback = ctx.link().callback(|_| ExportDropDownMenuMsg::TitleChange);
|
||||
let is_chart = ctx.props().renderer.is_chart();
|
||||
html! {
|
||||
<>
|
||||
<span class="dropdown-group-label">{ "Save as" }</span>
|
||||
<input
|
||||
class={if self.invalid { "invalid" } else { "" }}
|
||||
oninput={callback}
|
||||
ref={&self.input_ref}
|
||||
value={self.title.to_owned()}
|
||||
/>
|
||||
<DropDownMenu<ExportFile>
|
||||
values={Rc::new(get_menu_items(&self.title, is_chart))}
|
||||
callback={&ctx.props().callback}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ExportDropDownMenuMsg::TitleChange => {
|
||||
self.title = self
|
||||
.input_ref
|
||||
.cast::<web_sys::HtmlInputElement>()
|
||||
.unwrap()
|
||||
.value();
|
||||
|
||||
self.invalid = self.title.is_empty();
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
title: ctx
|
||||
.props()
|
||||
.session
|
||||
.get_title()
|
||||
.unwrap_or_else(|| "untitled".to_owned()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_menu_items(name: &str, is_chart: bool) -> Vec<ExportDropDownMenuItem> {
|
||||
vec![
|
||||
ExportDropDownMenuItem::OptGroup(
|
||||
"Current View".into(),
|
||||
if is_chart {
|
||||
vec![
|
||||
ExportMethod::Csv.new_file(name, is_chart),
|
||||
ExportMethod::Json.new_file(name, is_chart),
|
||||
ExportMethod::Ndjson.new_file(name, is_chart),
|
||||
ExportMethod::Arrow.new_file(name, is_chart),
|
||||
ExportMethod::Html.new_file(name, is_chart),
|
||||
ExportMethod::Plugin.new_file(name, is_chart),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ExportMethod::Csv.new_file(name, is_chart),
|
||||
ExportMethod::Json.new_file(name, is_chart),
|
||||
ExportMethod::Ndjson.new_file(name, is_chart),
|
||||
ExportMethod::Arrow.new_file(name, is_chart),
|
||||
ExportMethod::Html.new_file(name, is_chart),
|
||||
]
|
||||
},
|
||||
),
|
||||
ExportDropDownMenuItem::OptGroup("All".into(), vec![
|
||||
ExportMethod::CsvAll.new_file(name, is_chart),
|
||||
ExportMethod::JsonAll.new_file(name, is_chart),
|
||||
ExportMethod::NdjsonAll.new_file(name, is_chart),
|
||||
ExportMethod::ArrowAll.new_file(name, is_chart),
|
||||
]),
|
||||
ExportDropDownMenuItem::OptGroup("Config".into(), vec![
|
||||
ExportMethod::JsonConfig.new_file(name, is_chart),
|
||||
]),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use perspective_client::{ExprValidationError, clone};
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::form::code_editor::*;
|
||||
use crate::session::{Session, SessionMetadata, SessionMetadataRc};
|
||||
use crate::tasks::{ExprValidation, validate_expression};
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct ExpressionEditorProps {
|
||||
pub on_save: Callback<()>,
|
||||
pub on_validate: Callback<bool>,
|
||||
pub on_input: Callback<Rc<String>>,
|
||||
pub alias: Option<String>,
|
||||
pub disabled: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub reset_count: u8,
|
||||
|
||||
/// Session metadata snapshot — threaded from `SessionProps`.
|
||||
pub metadata: SessionMetadataRc,
|
||||
|
||||
/// Selected theme name, threaded for PortalModal consumers.
|
||||
#[prop_or_default]
|
||||
pub selected_theme: Option<String>,
|
||||
|
||||
// State
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExpressionEditorMsg {
|
||||
SetExpr(Rc<String>),
|
||||
ValidateComplete(ExprValidation),
|
||||
}
|
||||
|
||||
/// Expression editor component `CodeEditor` and a button toolbar.
|
||||
pub struct ExpressionEditor {
|
||||
expr: Rc<String>,
|
||||
error: Option<ExprValidationError>,
|
||||
oninput: Callback<Rc<String>>,
|
||||
|
||||
/// Monotonically increasing request id used to drop stale
|
||||
/// validation results when the user types faster than the engine
|
||||
/// can validate.
|
||||
validation_req_id: u64,
|
||||
|
||||
/// The id of the most recently dispatched validation; the result
|
||||
/// is only applied when its echoed id matches.
|
||||
last_dispatched_req_id: u64,
|
||||
}
|
||||
|
||||
impl Component for ExpressionEditor {
|
||||
type Message = ExpressionEditorMsg;
|
||||
type Properties = ExpressionEditorProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let oninput = ctx.link().callback(ExpressionEditorMsg::SetExpr);
|
||||
let expr = initial_expr(&ctx.props().metadata, &ctx.props().alias);
|
||||
ctx.link()
|
||||
.send_message(Self::Message::SetExpr(expr.clone()));
|
||||
|
||||
Self {
|
||||
error: None,
|
||||
expr,
|
||||
oninput,
|
||||
validation_req_id: 0,
|
||||
last_dispatched_req_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ExpressionEditorMsg::SetExpr(val) => {
|
||||
ctx.props().on_input.emit(val.clone());
|
||||
self.expr = val.clone();
|
||||
self.validation_req_id += 1;
|
||||
self.last_dispatched_req_id = self.validation_req_id;
|
||||
let cb = ctx.link().callback(ExpressionEditorMsg::ValidateComplete);
|
||||
validate_expression(
|
||||
&ctx.props().session,
|
||||
cb,
|
||||
self.validation_req_id,
|
||||
(*val).clone(),
|
||||
);
|
||||
true
|
||||
},
|
||||
ExpressionEditorMsg::ValidateComplete(result) => {
|
||||
if result.req_id != self.last_dispatched_req_id {
|
||||
// Stale result from a superseded request — ignore.
|
||||
return false;
|
||||
}
|
||||
self.error = result.error;
|
||||
if self.error.is_none() {
|
||||
let _: Option<bool> = try {
|
||||
let alias = ctx.props().alias.as_ref()?;
|
||||
let session = &ctx.props().session;
|
||||
let old = ctx.props().metadata.get_expression_by_alias(alias)?;
|
||||
let is_edited = *self.expr != old;
|
||||
session
|
||||
.metadata_mut()
|
||||
.set_edit_by_alias(alias, self.expr.to_string());
|
||||
|
||||
is_edited
|
||||
};
|
||||
|
||||
ctx.props().on_validate.emit(true);
|
||||
} else {
|
||||
ctx.props().on_validate.emit(false);
|
||||
}
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let disabled_class = ctx.props().disabled.then_some("disabled");
|
||||
clone!(ctx.props().disabled);
|
||||
html! {
|
||||
<>
|
||||
<label class="item_title">{ "Expression" }</label>
|
||||
<div id="editor-container" class={disabled_class}>
|
||||
<CodeEditor
|
||||
autofocus=true
|
||||
expr={&self.expr}
|
||||
autosuggest=true
|
||||
error={self.error.clone().map(|x| x.into())}
|
||||
{disabled}
|
||||
oninput={self.oninput.clone()}
|
||||
onsave={ctx.props().on_save.clone()}
|
||||
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
|
||||
/>
|
||||
<div id="psp-expression-editor-meta">
|
||||
<div class="error">
|
||||
{ &self.error.clone().map(|e| e.error_message).unwrap_or_default() }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
|
||||
if ctx.props().alias != old_props.alias
|
||||
|| ctx.props().reset_count != old_props.reset_count
|
||||
|| (ctx.props().alias.is_some() && ctx.props().metadata != old_props.metadata)
|
||||
{
|
||||
ctx.link()
|
||||
.send_message(ExpressionEditorMsg::SetExpr(initial_expr(
|
||||
&ctx.props().metadata,
|
||||
&ctx.props().alias,
|
||||
)));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initial_expr(metadata: &SessionMetadata, alias: &Option<String>) -> Rc<String> {
|
||||
alias
|
||||
.as_ref()
|
||||
.and_then(|alias| metadata.get_expression_by_alias(alias))
|
||||
.unwrap_or_default()
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use perspective_client::clone;
|
||||
use web_sys::*;
|
||||
use yew::html::ImplicitClone;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::portal::PortalModal;
|
||||
use super::style::StyleSurface;
|
||||
use crate::session::Session;
|
||||
use crate::utils::*;
|
||||
use crate::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FilterDropDownState {
|
||||
values: Vec<String>,
|
||||
selected: usize,
|
||||
on_select: Option<Callback<String>>,
|
||||
target: Option<HtmlElement>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FilterDropDownElement {
|
||||
state: Rc<RefCell<FilterDropDownState>>,
|
||||
session: Session,
|
||||
column: Rc<RefCell<Option<(usize, String)>>>,
|
||||
all_values: Rc<RefCell<Option<Vec<String>>>>,
|
||||
notify: Rc<PubSub<()>>,
|
||||
}
|
||||
|
||||
impl PartialEq for FilterDropDownElement {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.state, &other.state)
|
||||
}
|
||||
}
|
||||
|
||||
impl ImplicitClone for FilterDropDownElement {}
|
||||
|
||||
impl FilterDropDownElement {
|
||||
pub fn new(session: Session) -> Self {
|
||||
Self {
|
||||
state: Default::default(),
|
||||
session,
|
||||
column: Default::default(),
|
||||
all_values: Default::default(),
|
||||
notify: Rc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reautocomplete(&self) {
|
||||
// Re-open portal with current target
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
pub fn autocomplete(
|
||||
&self,
|
||||
column: (usize, String),
|
||||
input: String,
|
||||
exclude: HashSet<String>,
|
||||
target: HtmlElement,
|
||||
callback: Callback<String>,
|
||||
) {
|
||||
let current_column = self.column.borrow().clone();
|
||||
match current_column {
|
||||
Some(filter_col) if filter_col == column => {
|
||||
let values = filter_values(&input, &self.all_values, &exclude);
|
||||
if values.len() == 1 && values[0] == input {
|
||||
let _ = self.hide();
|
||||
} else {
|
||||
let mut s = self.state.borrow_mut();
|
||||
s.values = values;
|
||||
s.selected = 0;
|
||||
s.on_select = Some(callback);
|
||||
if s.target.is_none() {
|
||||
s.target = Some(target);
|
||||
}
|
||||
|
||||
drop(s);
|
||||
self.notify.emit(());
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
clone!(
|
||||
self.state,
|
||||
self.session,
|
||||
self.all_values,
|
||||
self.notify,
|
||||
old_column = self.column
|
||||
);
|
||||
ApiFuture::spawn(async move {
|
||||
let fetched =
|
||||
crate::queries::get_column_values(&session, column.1.clone()).await?;
|
||||
*all_values.borrow_mut() = Some(fetched);
|
||||
let values = filter_values(&input, &all_values, &exclude);
|
||||
let should_hide = values.len() == 1 && values[0] == input;
|
||||
|
||||
*old_column.borrow_mut() = Some(column);
|
||||
{
|
||||
let mut s = state.borrow_mut();
|
||||
s.on_select = Some(callback);
|
||||
if should_hide {
|
||||
let fv = self::filter_values("", &all_values, &exclude);
|
||||
s.values = fv;
|
||||
s.target = Some(target);
|
||||
} else {
|
||||
s.values = values;
|
||||
s.target = Some(target);
|
||||
}
|
||||
s.selected = 0;
|
||||
}
|
||||
if should_hide {
|
||||
state.borrow_mut().target = None;
|
||||
}
|
||||
|
||||
notify.emit(());
|
||||
Ok(())
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_select(&self) {
|
||||
let state = self.state.borrow();
|
||||
if let Some(value) = state.values.get(state.selected)
|
||||
&& let Some(ref cb) = state.on_select
|
||||
{
|
||||
cb.emit(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_down(&self) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.selected += 1;
|
||||
if state.selected >= state.values.len() {
|
||||
state.selected = 0;
|
||||
}
|
||||
|
||||
drop(state);
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
pub fn item_up(&self) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if state.selected < 1 {
|
||||
state.selected = state.values.len();
|
||||
}
|
||||
|
||||
state.selected -= 1;
|
||||
drop(state);
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
pub fn hide(&self) -> ApiResult<()> {
|
||||
self.state.borrow_mut().target = None;
|
||||
self.column.borrow_mut().take();
|
||||
self.notify.emit(());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct FilterDropDownPortalProps {
|
||||
pub element: FilterDropDownElement,
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
pub struct FilterDropDownPortal {
|
||||
_sub: Subscription,
|
||||
}
|
||||
|
||||
impl Component for FilterDropDownPortal {
|
||||
type Message = ();
|
||||
type Properties = FilterDropDownPortalProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let link = ctx.link().clone();
|
||||
let sub = ctx
|
||||
.props()
|
||||
.element
|
||||
.notify
|
||||
.add_listener(move |()| link.send_message(()));
|
||||
Self { _sub: sub }
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let state = ctx.props().element.state.borrow();
|
||||
let target = state.target.clone();
|
||||
let on_close = {
|
||||
let element = ctx.props().element.clone();
|
||||
Callback::from(move |()| {
|
||||
let _ = element.hide();
|
||||
})
|
||||
};
|
||||
|
||||
if target.is_some() {
|
||||
let values = state.values.clone();
|
||||
let selected = state.selected;
|
||||
let on_select = state.on_select.clone();
|
||||
drop(state);
|
||||
|
||||
html! {
|
||||
<PortalModal
|
||||
tag_name="perspective-dropdown"
|
||||
surface={StyleSurface::FilterDropdown}
|
||||
{target}
|
||||
own_focus=false
|
||||
{on_close}
|
||||
theme={ctx.props().theme.clone()}
|
||||
>
|
||||
<FilterDropDownView {values} {selected} {on_select} />
|
||||
</PortalModal>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct FilterDropDownViewProps {
|
||||
values: Vec<String>,
|
||||
selected: usize,
|
||||
on_select: Option<Callback<String>>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn FilterDropDownView(props: &FilterDropDownViewProps) -> Html {
|
||||
let body = html! {
|
||||
if !props.values.is_empty() {
|
||||
{ for props.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, value)| {
|
||||
let click = props.on_select.as_ref().unwrap().reform({
|
||||
let value = value.clone();
|
||||
move |_: MouseEvent| value.clone()
|
||||
});
|
||||
|
||||
html! {
|
||||
if idx == props.selected {
|
||||
<span onmousedown={click} class="selected">{ value }</span>
|
||||
} else {
|
||||
<span onmousedown={click}>{ value }</span>
|
||||
}
|
||||
}
|
||||
}) }
|
||||
} else {
|
||||
<span class="no-results">{ "No Completions" }</span>
|
||||
}
|
||||
};
|
||||
|
||||
html! { <>{ body }</> }
|
||||
}
|
||||
|
||||
fn filter_values(
|
||||
input: &str,
|
||||
values: &Rc<RefCell<Option<Vec<String>>>>,
|
||||
exclude: &HashSet<String>,
|
||||
) -> Vec<String> {
|
||||
let input = input.to_lowercase();
|
||||
if let Some(values) = &*values.borrow() {
|
||||
values
|
||||
.iter()
|
||||
.filter(|x| x.to_lowercase().contains(&input) && !exclude.contains(x.as_str()))
|
||||
.take(10)
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::cell::{Cell, Ref, RefCell};
|
||||
use std::future::Future;
|
||||
use std::iter::{Iterator, repeat_with};
|
||||
use std::rc::Rc;
|
||||
|
||||
use futures::future::{join_all, select_all};
|
||||
use perspective_js::utils::{global, *};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::{JsCast, intern};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::utils::*;
|
||||
|
||||
/// This test string is injected into the DOM with the target `font-family`
|
||||
/// applied. It is important for this string to contain the correct unicode
|
||||
/// range, as otherwise the browser may download the latin-only variant of the
|
||||
/// font which will later be invalidated.
|
||||
const FONT_TEST_SAMPLE: &str = "ABCDΔ";
|
||||
|
||||
/// How long to wait for the test string to receive the font before proceeding
|
||||
/// anyway (with a consoel warning).
|
||||
const FONT_DOWNLOAD_TIMEOUT_MS: i32 = 1000;
|
||||
|
||||
/// `state` is private to force construction of props with the `::new()` static
|
||||
/// method, which initializes the async `load_fonts_task()` method.
|
||||
#[derive(Clone, Properties)]
|
||||
pub struct FontLoaderProps {
|
||||
state: Rc<FontLoaderState>,
|
||||
}
|
||||
|
||||
impl PartialEq for FontLoaderProps {
|
||||
fn eq(&self, _rhs: &Self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(FontLoader)]
|
||||
pub fn font_loader(props: &FontLoaderProps) -> Html {
|
||||
if matches!(props.get_status(), FontLoaderStatus::Finished) {
|
||||
html! {}
|
||||
} else {
|
||||
let inner = props
|
||||
.get_fonts()
|
||||
.iter()
|
||||
.map(font_test_html)
|
||||
.collect::<Html>();
|
||||
|
||||
html! { <><style>{ ":host{opacity:0!important;}" }</style>{ inner }</> }
|
||||
}
|
||||
}
|
||||
|
||||
/// The possible font loading state, which proceeds from top to bottom once per
|
||||
/// `<perspective-viewer>` element.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum FontLoaderStatus {
|
||||
Uninitialized,
|
||||
Loading,
|
||||
Finished,
|
||||
}
|
||||
|
||||
struct FontLoaderState {
|
||||
status: Cell<FontLoaderStatus>,
|
||||
elem: web_sys::HtmlElement,
|
||||
on_update: Callback<()>,
|
||||
fonts: RefCell<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
type PromiseSet = Vec<ApiFuture<JsValue>>;
|
||||
|
||||
impl FontLoaderProps {
|
||||
pub fn new(elem: &web_sys::HtmlElement, on_update: Callback<()>) -> Self {
|
||||
let inner = FontLoaderState {
|
||||
status: Cell::new(FontLoaderStatus::Uninitialized),
|
||||
elem: elem.clone(),
|
||||
on_update,
|
||||
fonts: RefCell::new(vec![]),
|
||||
};
|
||||
|
||||
let state = yew::props!(Self {
|
||||
state: Rc::new(inner)
|
||||
});
|
||||
|
||||
ApiFuture::spawn(state.clone().load_fonts_task_safe());
|
||||
state
|
||||
}
|
||||
|
||||
pub fn get_status(&self) -> FontLoaderStatus {
|
||||
self.state.status.get()
|
||||
}
|
||||
|
||||
fn get_fonts(&'_ self) -> Ref<'_, Vec<(String, String)>> {
|
||||
self.state.fonts.borrow()
|
||||
}
|
||||
|
||||
/// We only want errors in this task to warn, since they are not necessarily
|
||||
/// error conditions and mainly of interest to developers.
|
||||
async fn load_fonts_task_safe(self) -> ApiResult<JsValue> {
|
||||
if let Err(msg) = self.load_fonts_task().await {
|
||||
web_sys::console::warn_1(&msg.into());
|
||||
};
|
||||
|
||||
Ok(JsValue::UNDEFINED)
|
||||
}
|
||||
|
||||
/// Awaits loading of a required set of font/weight pairs, given an element
|
||||
/// with a CSS variable of the format:
|
||||
/// ```css
|
||||
/// perspective-viewer {
|
||||
/// --preload-fonts: "Roboto Mono:200;Open Sans:300,400";
|
||||
/// }
|
||||
/// ```
|
||||
async fn load_fonts_task(self) -> ApiResult<JsValue> {
|
||||
await_dom_loaded().await?;
|
||||
let txt = global::window()
|
||||
.get_computed_style(&self.state.elem)?
|
||||
.unwrap()
|
||||
.get_property_value("--preload-fonts")?;
|
||||
|
||||
let mut block_promises: PromiseSet = vec![];
|
||||
let preload_fonts = parse_fonts(&txt);
|
||||
self.state.fonts.borrow_mut().clone_from(&preload_fonts);
|
||||
self.state.status.set(FontLoaderStatus::Loading);
|
||||
self.state.on_update.emit(());
|
||||
|
||||
for (family, weight) in preload_fonts.iter() {
|
||||
let task = timeout_font_task(family, weight);
|
||||
let mut block_fonts: PromiseSet = vec![ApiFuture::new(task)];
|
||||
|
||||
for entry in font_iter(global::document().fonts().values()) {
|
||||
let font_face = js_sys::Reflect::get(&entry, &intern("value").into())?
|
||||
.dyn_into::<web_sys::FontFace>()?;
|
||||
|
||||
// Safari always has to be "different".
|
||||
if family == &font_face.family().replace('"', "")
|
||||
&& (weight == &font_face.weight()
|
||||
|| (font_face.weight() == "normal" && weight == "400"))
|
||||
{
|
||||
block_fonts.push(ApiFuture::new(async move {
|
||||
Ok(JsFuture::from(font_face.loaded()?).await?)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let fut = async { select_all(block_fonts).await.0 };
|
||||
block_promises.push(ApiFuture::new(fut))
|
||||
}
|
||||
|
||||
if block_promises.len() != preload_fonts.len() {
|
||||
web_sys::console::warn_1(&format!("Missing preload fonts {:?}", preload_fonts).into());
|
||||
}
|
||||
|
||||
let res = join_all(block_promises)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<ApiResult<Vec<JsValue>>>()
|
||||
.map(|_| JsValue::UNDEFINED);
|
||||
|
||||
self.state.status.set(FontLoaderStatus::Finished);
|
||||
self.state.on_update.emit(());
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
// An async task which times out. Can be used to timeout an optional async task
|
||||
// by combinging with `Promise::any`.
|
||||
fn timeout_font_task(
|
||||
family: &str,
|
||||
weight: &str,
|
||||
) -> impl Future<Output = ApiResult<JsValue>> + use<> {
|
||||
let timeout_msg = format!("Timeout awaiting font \"{family}:{weight}\"");
|
||||
async {
|
||||
set_timeout(FONT_DOWNLOAD_TIMEOUT_MS).await?;
|
||||
Err(timeout_msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a `<span>` for a specific font family and weight with `opacity:0`,
|
||||
/// since not all of the fonts may be shown and when e.g. the settings panel is
|
||||
/// closed, and this will defer font loading.
|
||||
fn font_test_html((family, weight): &(String, String)) -> Html {
|
||||
let style = format!("opacity:0;font-family:\"{family}\";font-weight:{weight}");
|
||||
|
||||
html! { <span {style}>{ FONT_TEST_SAMPLE }</span> }
|
||||
}
|
||||
|
||||
fn parse_font(txt: &str) -> Option<Vec<(String, String)>> {
|
||||
match *txt.trim().split(':').collect::<Vec<_>>().as_slice() {
|
||||
[family, weights] => Some(
|
||||
weights
|
||||
.split(',')
|
||||
.map(|weight| (family.to_owned(), weight.to_owned()))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `--preload-fonts` value into (Family, Weight) tuples.
|
||||
fn parse_fonts(txt: &str) -> Vec<(String, String)> {
|
||||
let trim = txt.trim();
|
||||
let trim = if trim.len() > 2 {
|
||||
&trim[1..trim.len() - 1]
|
||||
} else {
|
||||
trim
|
||||
};
|
||||
|
||||
trim.split(';')
|
||||
.filter_map(parse_font)
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// wasm_bindgen doesn't fully implement `FontFaceIterator`, but this is
|
||||
/// basically how it would be implemented if it was.
|
||||
fn font_iter(
|
||||
iter: web_sys::FontFaceSetIterator,
|
||||
) -> impl Iterator<Item = web_sys::FontFaceSetIteratorResult> {
|
||||
repeat_with(move || iter.next())
|
||||
.filter_map(|x| x.ok())
|
||||
.take_while(|entry| {
|
||||
!js_sys::Reflect::get(entry, &intern("done").into())
|
||||
.unwrap()
|
||||
.as_bool()
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use perspective_client::ExprValidationError;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::form::highlight::highlight;
|
||||
use crate::components::function_dropdown::{FunctionDropDownElement, FunctionDropDownPortal};
|
||||
use crate::exprtk::{Cursor, tokenize};
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CodeEditorProps {
|
||||
pub expr: Rc<String>,
|
||||
pub oninput: Callback<Rc<String>>,
|
||||
pub onsave: Callback<()>,
|
||||
pub disabled: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub autofocus: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub wordwrap: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub autosuggest: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub select_all: Subscriber<()>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub error: Option<ExprValidationError>,
|
||||
|
||||
/// Selected theme name, threaded for PortalModal consumers.
|
||||
#[prop_or_default]
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
/// A syntax-highlighted text editor component.
|
||||
#[function_component(CodeEditor)]
|
||||
pub fn code_editor(props: &CodeEditorProps) -> Html {
|
||||
let select_all = use_state_eq(|| false);
|
||||
let caret_position = use_state_eq(|| 0_u32);
|
||||
let scroll_offset = use_state_eq(|| (0, 0));
|
||||
let textarea_ref = use_node_ref();
|
||||
let content_ref = use_node_ref();
|
||||
let lineno_ref = use_node_ref();
|
||||
let filter_dropdown = use_memo((), |_| FunctionDropDownElement::default());
|
||||
let mut cursor = Cursor::new(&props.error);
|
||||
let terms = tokenize(&props.expr)
|
||||
.into_iter()
|
||||
.map(|token| highlight(&mut cursor, token, *caret_position))
|
||||
.collect::<Html>();
|
||||
|
||||
let onkeydown = use_callback((caret_position.setter(), props.onsave.clone()), on_keydown);
|
||||
let oninput = use_callback(props.oninput.clone(), |event, deps| {
|
||||
on_input_callback(event, deps)
|
||||
});
|
||||
|
||||
let onscroll = use_callback((scroll_offset.setter(), textarea_ref.clone()), |_, deps| {
|
||||
on_scroll(&deps.0, &deps.1)
|
||||
});
|
||||
|
||||
let autofocus = props.autofocus;
|
||||
use_effect_with((props.expr.clone(), textarea_ref.clone()), {
|
||||
move |(expr, textarea_ref)| {
|
||||
let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
|
||||
if autofocus {
|
||||
elem.focus().unwrap();
|
||||
}
|
||||
|
||||
if **expr != elem.value() {
|
||||
elem.set_value(&format!("{expr}"));
|
||||
elem.set_scroll_top(0);
|
||||
elem.set_scroll_left(0);
|
||||
elem.set_caret_position(0).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// select_all.set(props.select_all);
|
||||
use_effect_with((select_all.setter(), props.select_all.clone()), {
|
||||
move |(select_all, props_select_all)| {
|
||||
clone!(select_all);
|
||||
let sub = props_select_all.add_listener(move |()| select_all.set(true));
|
||||
move || drop(sub)
|
||||
}
|
||||
});
|
||||
|
||||
// select_all.set(props.select_all);
|
||||
use_effect_with((select_all, textarea_ref.clone()), {
|
||||
move |(select_all, textarea_ref)| {
|
||||
let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
|
||||
if **select_all {
|
||||
elem.focus().unwrap();
|
||||
elem.select_all().unwrap();
|
||||
}
|
||||
|
||||
select_all.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ????
|
||||
let autofocus = props.autofocus;
|
||||
use_effect_with((props.error.clone(), textarea_ref.clone()), {
|
||||
move |(_, textarea_ref)| {
|
||||
if autofocus {
|
||||
let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
|
||||
elem.focus().unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sync scrolling between textarea and formatted HTML
|
||||
use_effect_with(
|
||||
(scroll_offset, content_ref.clone(), lineno_ref.clone()),
|
||||
|deps| scroll_sync(&deps.0, &deps.1, &deps.2),
|
||||
);
|
||||
|
||||
let portal_dropdown = filter_dropdown.clone();
|
||||
|
||||
// Blur if this element is not in the tree
|
||||
use_effect_with(filter_dropdown.clone(), |filter_dropdown| {
|
||||
clone!(filter_dropdown);
|
||||
move || filter_dropdown.hide().unwrap()
|
||||
});
|
||||
|
||||
// Show autocomplete
|
||||
use_effect_with(
|
||||
(
|
||||
props.autosuggest,
|
||||
filter_dropdown,
|
||||
cursor.auto.clone(),
|
||||
cursor.noderef.clone(),
|
||||
),
|
||||
|deps| {
|
||||
if deps.0 {
|
||||
autocomplete(&deps.1, &deps.2, &deps.3)
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let error_line = props.error.as_ref().map(|x| x.line);
|
||||
let line_numbers = cursor
|
||||
.map_rows(|x| html!(
|
||||
<span
|
||||
class={if Some(x) == error_line {"line_number error_highlight"} else {"line_number"}}
|
||||
>
|
||||
{ x + 1 }
|
||||
</span>
|
||||
))
|
||||
.collect::<Html>();
|
||||
|
||||
let class = if props.wordwrap {
|
||||
"wordwrap scrollable"
|
||||
} else {
|
||||
"scrollable"
|
||||
};
|
||||
clone!(props.disabled);
|
||||
html! {
|
||||
<>
|
||||
<div id="editor" {class}>
|
||||
<div id="line_numbers" ref={lineno_ref}>{ line_numbers }</div>
|
||||
<div id="editor-inner" {class}>
|
||||
<textarea
|
||||
{disabled}
|
||||
id="textarea_editable"
|
||||
class="scrollable"
|
||||
ref={textarea_ref}
|
||||
spellcheck="false"
|
||||
{oninput}
|
||||
{onscroll}
|
||||
{onkeydown}
|
||||
/>
|
||||
<div id="editor-height-sizer" />
|
||||
<pre id="content" ref={content_ref}>
|
||||
{ terms }
|
||||
{ {
|
||||
// A linebreak which pushs a textarea into scroll overflow
|
||||
// may not necessarily do so in the `<pre>`, because there is
|
||||
// no cursor when the last line has no content, so add
|
||||
// some space here to make sure overlfow is in sync
|
||||
// with the text area.
|
||||
" "
|
||||
} }
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<FunctionDropDownPortal
|
||||
element={(*portal_dropdown).clone()}
|
||||
theme={props.theme.clone()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture the input (for re-parse) and caret position whenever the input
|
||||
/// text changes.
|
||||
fn on_input_callback(
|
||||
event: InputEvent,
|
||||
// position: &UseStateSetter<u32>,
|
||||
oninput: &Callback<Rc<String>>,
|
||||
) {
|
||||
let elem = event
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlTextAreaElement>();
|
||||
|
||||
oninput.emit(elem.value().into());
|
||||
// position.set(elem.get_caret_position().unwrap_or_default());
|
||||
}
|
||||
|
||||
/// Overide for special non-character commands e.g. shift+enter
|
||||
fn on_keydown(event: KeyboardEvent, deps: &(UseStateSetter<u32>, Callback<()>)) {
|
||||
let elem = event
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlTextAreaElement>();
|
||||
|
||||
deps.0.set(elem.get_caret_position().unwrap_or_default());
|
||||
if event.shift_key() && event.key_code() == 13 {
|
||||
event.prevent_default();
|
||||
deps.1.emit(())
|
||||
}
|
||||
|
||||
// handle the tab key press
|
||||
if event.key() == "Tab" {
|
||||
event.prevent_default();
|
||||
|
||||
let caret_pos = elem.selection_start().unwrap().unwrap_or_default() as usize;
|
||||
|
||||
let mut initial_text = elem.value();
|
||||
|
||||
initial_text.insert(caret_pos, '\t');
|
||||
|
||||
elem.set_value(&initial_text);
|
||||
|
||||
let input_event = web_sys::InputEvent::new("input").unwrap();
|
||||
let _ = elem.dispatch_event(&input_event).unwrap();
|
||||
|
||||
// place caret after inserted tab
|
||||
let new_caret_pos = (caret_pos + 1) as u32;
|
||||
let _ = elem.set_selection_range(new_caret_pos, new_caret_pos);
|
||||
|
||||
elem.focus().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolling callback
|
||||
fn on_scroll(scroll: &UseStateSetter<(i32, i32)>, editable: &NodeRef) {
|
||||
let div = editable.cast::<HtmlElement>().unwrap();
|
||||
scroll.set((div.scroll_top(), div.scroll_left()));
|
||||
}
|
||||
|
||||
/// Scrolling sync
|
||||
fn scroll_sync(scroll: &UseStateHandle<(i32, i32)>, div: &NodeRef, lineno: &NodeRef) {
|
||||
if let Some(div) = div.cast::<HtmlElement>() {
|
||||
div.set_scroll_top(scroll.0);
|
||||
div.set_scroll_left(scroll.1);
|
||||
}
|
||||
|
||||
if let Some(div) = lineno.cast::<HtmlElement>() {
|
||||
div.set_scroll_top(scroll.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Autocomplete
|
||||
/// TODO this should use a portal
|
||||
fn autocomplete(
|
||||
filter_dropdown: &Rc<FunctionDropDownElement>,
|
||||
token: &Option<String>,
|
||||
target: &NodeRef,
|
||||
) {
|
||||
if let Some(x) = token {
|
||||
let elem = target.cast::<HtmlElement>().unwrap();
|
||||
if elem.is_connected() {
|
||||
filter_dropdown
|
||||
.autocomplete(x.clone(), elem, Callback::from(|_| ()))
|
||||
.unwrap();
|
||||
} else {
|
||||
filter_dropdown.hide().unwrap();
|
||||
}
|
||||
} else {
|
||||
filter_dropdown.hide().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct ColorRangeProps {
|
||||
/// Extra class appended to the positive input's `class` attribute,
|
||||
/// so callers can target the pos / neg sides independently from CSS.
|
||||
/// Paired with `neg_class`; both default to empty.
|
||||
#[prop_or_default]
|
||||
pub pos_class: Classes,
|
||||
#[prop_or_default]
|
||||
pub neg_class: Classes,
|
||||
pub pos_color: String,
|
||||
pub neg_color: String,
|
||||
pub is_gradient: bool,
|
||||
pub on_pos_color: Callback<String>,
|
||||
pub on_neg_color: Callback<String>,
|
||||
pub on_reset: Callback<()>,
|
||||
pub is_modified: bool,
|
||||
}
|
||||
|
||||
#[function_component(ColorRangeSelector)]
|
||||
pub fn color_chooser_component(props: &ColorRangeProps) -> Html {
|
||||
let on_pos_color = use_callback(
|
||||
props.on_pos_color.clone(),
|
||||
|event: InputEvent, on_pos_color| {
|
||||
let color = event
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value();
|
||||
on_pos_color.emit(color);
|
||||
},
|
||||
);
|
||||
|
||||
let on_neg_color = use_callback(
|
||||
props.on_neg_color.clone(),
|
||||
|event: InputEvent, on_neg_color| {
|
||||
let color = event
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value();
|
||||
on_neg_color.emit(color);
|
||||
},
|
||||
);
|
||||
|
||||
let fg_pos = infer_fg(&props.pos_color);
|
||||
let fg_neg = infer_fg(&props.neg_color);
|
||||
let style = if props.is_gradient {
|
||||
format!(
|
||||
"background:linear-gradient(to right, {} 0%, transparent 50%, {} 100%)",
|
||||
props.pos_color, props.neg_color
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"background:linear-gradient(to right, {} 0%, {} 50%, {} 50%, {} 100%)",
|
||||
props.pos_color, props.pos_color, props.neg_color, props.neg_color
|
||||
)
|
||||
};
|
||||
|
||||
let on_reset = use_callback(props.clone(), |_: MouseEvent, deps| deps.on_reset.emit(()));
|
||||
|
||||
let pos_class = classes!("parameter", "pos-color-param", props.pos_class.clone());
|
||||
let neg_class = classes!("parameter", "neg-color-param", props.neg_class.clone());
|
||||
|
||||
html! {
|
||||
<>
|
||||
<label id="color-range-label" />
|
||||
<div class="color-gradient-container">
|
||||
<div style={fg_pos} class="color-selector">
|
||||
<input
|
||||
class={pos_class}
|
||||
type="color"
|
||||
value={props.pos_color.to_owned()}
|
||||
data-value={props.pos_color.to_owned()}
|
||||
oninput={on_pos_color}
|
||||
/>
|
||||
<label class="color-label">{ "+" }</label>
|
||||
</div>
|
||||
<div class="color-thermometer" {style} />
|
||||
<div style={fg_neg} class="color-selector">
|
||||
<input
|
||||
class={neg_class}
|
||||
type="color"
|
||||
value={props.neg_color.to_owned()}
|
||||
data-value={props.neg_color.to_owned()}
|
||||
oninput={on_neg_color}
|
||||
/>
|
||||
<label class="color-label">{ "-" }</label>
|
||||
</div>
|
||||
if props.is_modified {
|
||||
<span class="reset-default-style" onclick={on_reset} />
|
||||
} else {
|
||||
<span class="reset-default-style-disabled" />
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_fg(color: &str) -> &'static str {
|
||||
if color.len() >= 7 {
|
||||
let r = i32::from_str_radix(&color[1..3], 16).unwrap_or(255) as f64;
|
||||
let g = i32::from_str_radix(&color[3..5], 16).unwrap_or(0) as f64;
|
||||
let b = i32::from_str_radix(&color[5..7], 16).unwrap_or(0) as f64;
|
||||
if (r * r * 0.299 + g * g * 0.587 + b * b * 0.114).sqrt() > 130.0 {
|
||||
"--sign--color:var(--psp--color)"
|
||||
} else {
|
||||
"--sign--color:var(--psp--background-color)"
|
||||
}
|
||||
} else if color.len() == 4 {
|
||||
let r = i32::from_str_radix(&color[1..2], 16).unwrap_or(15) as f64;
|
||||
let g = i32::from_str_radix(&color[2..3], 16).unwrap_or(0) as f64;
|
||||
let b = i32::from_str_radix(&color[3..4], 16).unwrap_or(0) as f64;
|
||||
if (r * r * 0.299 + g * g * 0.587 + b * b * 0.114).sqrt() > 8.0 {
|
||||
"--sign--color:var(--psp--color)"
|
||||
} else {
|
||||
"--sign--color:var(--psp--background-color)"
|
||||
}
|
||||
} else {
|
||||
"--sign--color:var(--psp--color)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ColorProps {
|
||||
pub color: String,
|
||||
pub on_color: Callback<String>,
|
||||
pub on_reset: Callback<()>,
|
||||
pub is_modified: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(ColorSelector)]
|
||||
pub fn color_component(props: &ColorProps) -> Html {
|
||||
let oninput = use_callback(props.on_color.clone(), |event: InputEvent, deps| {
|
||||
let color = event
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value();
|
||||
deps.emit(color);
|
||||
});
|
||||
|
||||
let on_reset = use_callback(props.on_reset.clone(), |_: MouseEvent, on_reset| {
|
||||
on_reset.emit(())
|
||||
});
|
||||
|
||||
html! {
|
||||
<>
|
||||
<label id={props.title.as_deref().unwrap_or("color-label").to_owned()} />
|
||||
<div class="color-gradient-container">
|
||||
<input class="parameter" type="color" value={props.color.to_owned()} {oninput} />
|
||||
if props.is_modified {
|
||||
<span class="reset-default-style" onclick={on_reset} />
|
||||
} else {
|
||||
<span class="reset-default-style-disabled" />
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use perspective_client::ExprValidationError;
|
||||
use perspective_js::utils::{ApiFuture, JsValueSerdeExt};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::components::form::code_editor::CodeEditor;
|
||||
use crate::js::{MimeType, copy_to_clipboard, paste_from_clipboard};
|
||||
use crate::presentation::*;
|
||||
use crate::renderer::*;
|
||||
use crate::session::*;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct DebugPanelProps {
|
||||
pub presentation: Presentation,
|
||||
pub renderer: Renderer,
|
||||
pub session: Session,
|
||||
|
||||
/// Trap-door width pinned by the parent `SettingsPanel` so switching
|
||||
/// tabs doesn't shrink the panel. Threaded into the hidden sizer
|
||||
/// `<div class="scroll-panel-auto-width">`.
|
||||
#[prop_or_default]
|
||||
pub initial_width: f64,
|
||||
|
||||
/// Fires once on mount with this panel's measured natural width.
|
||||
/// Routed up to `SettingsPanel` which keeps the running max.
|
||||
#[prop_or_default]
|
||||
pub on_auto_width: Callback<f64>,
|
||||
}
|
||||
|
||||
#[function_component(DebugPanel)]
|
||||
pub fn debug_panel(props: &DebugPanelProps) -> Html {
|
||||
let expr = use_state_eq(|| Rc::new("".to_string()));
|
||||
let error = use_state_eq(|| Option::<ExprValidationError>::None);
|
||||
let select_all = use_memo((), |()| PubSub::default());
|
||||
let modified = use_state_eq(|| false);
|
||||
|
||||
// Measure natural width on mount and route up to `SettingsPanel`.
|
||||
let sizer = use_node_ref();
|
||||
use_effect_with(expr.clone(), {
|
||||
let sizer = sizer.clone();
|
||||
let on_auto_width = props.on_auto_width.clone();
|
||||
move |_| {
|
||||
if let Some(elem) = sizer.cast::<web_sys::HtmlElement>() {
|
||||
on_auto_width.emit(elem.get_bounding_client_rect().width());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
use_effect_with((expr.setter(), props.clone()), {
|
||||
clone!(error, modified);
|
||||
move |(text, state)| {
|
||||
state.set_text(text.clone());
|
||||
error.set(None);
|
||||
let sub1 = state
|
||||
.renderer
|
||||
.style_changed
|
||||
.add_listener(state.reset_callback(
|
||||
text.clone(),
|
||||
error.setter(),
|
||||
modified.setter(),
|
||||
));
|
||||
|
||||
let sub2 = state
|
||||
.renderer
|
||||
.reset_changed
|
||||
.add_listener(state.reset_callback(
|
||||
text.clone(),
|
||||
error.setter(),
|
||||
modified.setter(),
|
||||
));
|
||||
|
||||
let sub3 = state
|
||||
.session
|
||||
.view_config_changed
|
||||
.add_listener(state.reset_callback(
|
||||
text.clone(),
|
||||
error.setter(),
|
||||
modified.setter(),
|
||||
));
|
||||
|
||||
|| {
|
||||
drop(sub1);
|
||||
drop(sub2);
|
||||
drop(sub3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let oninput = use_callback(expr.setter(), {
|
||||
clone!(modified);
|
||||
move |x, expr| {
|
||||
modified.set(true);
|
||||
expr.set(x)
|
||||
}
|
||||
});
|
||||
|
||||
let onsave = use_callback((expr.clone(), error.clone(), props.clone()), {
|
||||
clone!(modified);
|
||||
move |_, (text, error, props)| props.on_save(text, error, &modified)
|
||||
});
|
||||
|
||||
let oncopy = use_callback(
|
||||
(expr.clone(), select_all.callback()),
|
||||
move |_, (text, select_all)| {
|
||||
select_all.emit(());
|
||||
let options = web_sys::BlobPropertyBag::new();
|
||||
options.set_type("text/plain");
|
||||
let blob_txt = (JsValue::from((***text).clone())).clone();
|
||||
let blob_parts = js_sys::Array::from_iter([blob_txt].iter());
|
||||
let blob = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &options);
|
||||
ApiFuture::spawn(copy_to_clipboard(
|
||||
async move { Ok(blob?) },
|
||||
MimeType::TextPlain,
|
||||
));
|
||||
},
|
||||
);
|
||||
|
||||
let onapply = use_callback((expr.clone(), error.clone(), props.clone()), {
|
||||
clone!(modified);
|
||||
move |_, (text, error, props)| props.on_save(text, error, &modified)
|
||||
});
|
||||
|
||||
let onreset = use_callback((expr.setter(), error.clone(), props.clone()), {
|
||||
clone!(modified);
|
||||
move |_, (text, error, props)| {
|
||||
props.set_text(text.clone());
|
||||
error.set(None);
|
||||
modified.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
let onpaste = use_callback((expr.clone(), error.clone(), props.clone()), {
|
||||
clone!(modified);
|
||||
move |_, (text, error, props)| {
|
||||
clone!(text, error, props, modified);
|
||||
ApiFuture::spawn(async move {
|
||||
if let Some(x) = paste_from_clipboard().await {
|
||||
let x = Rc::new(x);
|
||||
modified.set(true);
|
||||
error.set(None);
|
||||
text.set(x.clone());
|
||||
props.on_save(&x, &error, &modified);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div id="debug-panel-overflow">
|
||||
<div id="debug-panel" class="sidebar_column" ref={sizer}>
|
||||
<div id="debug-panel-controls">
|
||||
<button disabled={!*modified} onclick={onapply}>{ "Apply" }</button>
|
||||
<button disabled={!*modified} onclick={onreset}>{ "Reset" }</button>
|
||||
<button onclick={oncopy}>{ "Copy" }</button>
|
||||
<button onclick={onpaste}>{ "Paste" }</button>
|
||||
</div>
|
||||
<div id="debug-panel-editor">
|
||||
<CodeEditor
|
||||
expr={&*expr}
|
||||
disabled=false
|
||||
{oninput}
|
||||
{onsave}
|
||||
select_all={select_all.subscriber()}
|
||||
error={(*error).clone()}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="scroll-panel-auto-width"
|
||||
style={format!("width:{}px", props.initial_width)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
impl DebugPanelProps {
|
||||
fn set_text(&self, setter: UseStateSetter<Rc<String>>) {
|
||||
let props = self.clone();
|
||||
ApiFuture::spawn(async move {
|
||||
let config = crate::queries::get_viewer_config(
|
||||
&props.session,
|
||||
&props.renderer,
|
||||
&props.presentation,
|
||||
)
|
||||
.await?;
|
||||
let json = JsValue::from_serde_ext(&config)?;
|
||||
let js_string =
|
||||
js_sys::JSON::stringify_with_replacer_and_space(&json, &JsValue::NULL, &2.into())?;
|
||||
|
||||
setter.set(Rc::new(js_string.as_string().unwrap()));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
fn reset_callback(
|
||||
&self,
|
||||
text: UseStateSetter<Rc<String>>,
|
||||
error: UseStateSetter<Option<ExprValidationError>>,
|
||||
modified: UseStateSetter<bool>,
|
||||
) -> impl Fn(()) + use<> {
|
||||
let props = self.clone();
|
||||
move |_| {
|
||||
error.set(None);
|
||||
props.set_text(text.clone());
|
||||
modified.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_save(
|
||||
&self,
|
||||
text: &Rc<String>,
|
||||
error: &UseStateHandle<Option<ExprValidationError>>,
|
||||
modified: &UseStateHandle<bool>,
|
||||
) {
|
||||
let props = self.clone();
|
||||
clone!(text, error, modified);
|
||||
ApiFuture::spawn(async move {
|
||||
match serde_json::from_str(&text) {
|
||||
Ok(config) => {
|
||||
match crate::tasks::restore_and_render(
|
||||
&props.session,
|
||||
&props.renderer,
|
||||
&props.presentation,
|
||||
config,
|
||||
async { Ok(()) },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
modified.set(false);
|
||||
},
|
||||
Err(e) => {
|
||||
modified.set(true);
|
||||
error.set(Some(ExprValidationError {
|
||||
error_message: JsValue::from(e).as_string().unwrap_or_else(|| {
|
||||
"Failed to validate viewer config".to_owned()
|
||||
}),
|
||||
line: 0_u32,
|
||||
column: 0,
|
||||
}));
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
Err(err) => {
|
||||
modified.set(true);
|
||||
error.set(Some(ExprValidationError {
|
||||
error_message: err.to_string(),
|
||||
line: err.line() as u32 - 1,
|
||||
column: err.column() as u32 - 1,
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::exprtk::{Cursor, Token};
|
||||
|
||||
/// Highlight a token if the cursor overlaps an error. This is not a
|
||||
/// `Component` because of the the lifetimes associated with `Cursor<'a>` etc.
|
||||
/// This makes the performance assumption that naively re-rendering is faster
|
||||
/// than allocating this data on the heap (plus a few copies), since virtually
|
||||
/// any update to the `CodeEditor` component will be an update to the parsed
|
||||
/// text.
|
||||
///
|
||||
/// TODO Validate this assumption with actual data.
|
||||
pub fn highlight<'a>(cursor: &mut Cursor<'a>, token: Token<'a>, position: u32) -> Html {
|
||||
cursor.txt = token.content();
|
||||
let is_auto = cursor.is_autocomplete(position);
|
||||
let is_break = matches!(token, Token::Break(_));
|
||||
let is_overlap = cursor.is_error();
|
||||
let result = match (is_auto, is_overlap, is_break) {
|
||||
(true, true, false) => html! {
|
||||
<span ref={cursor.noderef.clone()} class="error_highlight">{ token.to_html() }</span>
|
||||
},
|
||||
(false, true, false) => html! { <span class="error_highlight">{ token.to_html() }</span> },
|
||||
(true, false, false) => {
|
||||
html! { <span ref={cursor.noderef.clone()}>{ token.to_html() }</span> }
|
||||
},
|
||||
_ => token.to_html(),
|
||||
};
|
||||
|
||||
if is_auto && matches!(token, Token::Symbol(_)) {
|
||||
cursor.auto = Some(token.content().to_owned());
|
||||
}
|
||||
|
||||
if matches!(token, Token::Break(_)) {
|
||||
cursor.increment_line();
|
||||
} else {
|
||||
cursor.increment_column(token.content().len() as u32);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
//! A module for form controls (though form controls like `<Select>` which are
|
||||
//! also container elements should go in the `containers` module).
|
||||
//!
|
||||
//! Components in this module should not be imported from the `components`
|
||||
//! parent module directly.
|
||||
|
||||
pub mod code_editor;
|
||||
pub mod color_range_selector;
|
||||
pub mod color_selector;
|
||||
pub mod debug;
|
||||
pub mod number_field;
|
||||
pub mod number_input;
|
||||
pub mod number_range_field;
|
||||
pub mod optional_field;
|
||||
pub mod select_enum_field;
|
||||
pub mod select_value_field;
|
||||
|
||||
mod highlight;
|
||||
@@ -0,0 +1,72 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use web_sys::{HtmlInputElement, InputEvent};
|
||||
use yew::{Callback, Properties, TargetCast, function_component, html};
|
||||
|
||||
use crate::components::form::optional_field::OptionalField;
|
||||
|
||||
#[derive(Properties, Debug, PartialEq, Clone)]
|
||||
pub struct NumberFieldProps {
|
||||
pub label: String,
|
||||
pub current_value: Option<f64>,
|
||||
pub default: f64,
|
||||
pub on_change: Callback<Option<f64>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub disabled: bool,
|
||||
|
||||
#[prop_or_default]
|
||||
pub min: Option<f64>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub max: Option<f64>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub step: Option<f64>,
|
||||
}
|
||||
|
||||
#[function_component(NumberField)]
|
||||
pub fn number_field(props: &NumberFieldProps) -> yew::Html {
|
||||
let parse_number_input = |event: InputEvent| {
|
||||
Some(
|
||||
event
|
||||
.target_unchecked_into::<HtmlInputElement>()
|
||||
.value_as_number(),
|
||||
)
|
||||
};
|
||||
|
||||
let number_input = html! {
|
||||
<input
|
||||
type="number"
|
||||
class="parameter"
|
||||
min={props.min.map(|val| val.to_string())}
|
||||
max={props.max.map(|val| val.to_string())}
|
||||
step={props.step.map(|val| val.to_string())}
|
||||
value={props.current_value.unwrap_or(props.default).to_string()}
|
||||
oninput={props.on_change.reform(parse_number_input)}
|
||||
/>
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<OptionalField
|
||||
label={props.label.clone()}
|
||||
on_check={props.on_change.reform(|_| None)}
|
||||
checked={props.current_value.map(|val| val != props.default).unwrap_or_default()}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{ number_input }
|
||||
</OptionalField>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use wasm_bindgen::*;
|
||||
use web_sys::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct NumberInputProps {
|
||||
pub max_value: f64,
|
||||
pub on_max_value: Callback<String>,
|
||||
}
|
||||
|
||||
#[function_component(NumberInput)]
|
||||
pub fn number_input(props: &NumberInputProps) -> Html {
|
||||
let oninput = props.on_max_value.reform(|event: InputEvent| {
|
||||
event
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value()
|
||||
});
|
||||
|
||||
html! {
|
||||
<>
|
||||
<label>{ "Max" }</label>
|
||||
<input
|
||||
value={format!("{}", props.max_value)}
|
||||
class="parameter"
|
||||
type="number"
|
||||
min="0"
|
||||
{oninput}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::{Callback, Properties, function_component, html, use_callback, use_node_ref};
|
||||
|
||||
use crate::components::form::optional_field::OptionalField;
|
||||
|
||||
#[derive(Properties, Debug, PartialEq, Clone)]
|
||||
pub struct NumberRangeFieldProps {
|
||||
pub label: String,
|
||||
pub current_value: Option<(f64, f64)>,
|
||||
pub default: (f64, f64),
|
||||
pub on_change: Callback<Option<(f64, f64)>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub min: Option<f64>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub max: Option<f64>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub step: Option<f64>,
|
||||
}
|
||||
|
||||
#[function_component(NumberRangeField)]
|
||||
pub fn number_range_field(props: &NumberRangeFieldProps) -> yew::Html {
|
||||
let ref1 = use_node_ref();
|
||||
let ref2 = use_node_ref();
|
||||
let parse_number_input = use_callback(
|
||||
(ref1.clone(), ref2.clone(), props.on_change.clone()),
|
||||
|_, deps| {
|
||||
deps.2.emit(Some((
|
||||
deps.0.cast::<HtmlInputElement>().unwrap().value_as_number(),
|
||||
deps.1.cast::<HtmlInputElement>().unwrap().value_as_number(),
|
||||
)));
|
||||
},
|
||||
);
|
||||
|
||||
html! {
|
||||
<OptionalField
|
||||
label={props.label.clone()}
|
||||
on_check={props.on_change.reform(|_| None)}
|
||||
checked={props.current_value.map(|val| val != props.default).unwrap_or_default()}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
class="parameter parameter-min"
|
||||
ref={ref1}
|
||||
min={props.min.map(|val| val.to_string())}
|
||||
max={props.max.map(|val| val.to_string())}
|
||||
step={props.step.map(|val| val.to_string())}
|
||||
value={props.current_value.unwrap_or(props.default).0.to_string()}
|
||||
oninput={parse_number_input.clone()}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="parameter parameter-max"
|
||||
ref={ref2}
|
||||
min={props.min.map(|val| val.to_string())}
|
||||
max={props.max.map(|val| val.to_string())}
|
||||
step={props.step.map(|val| val.to_string())}
|
||||
value={props.current_value.unwrap_or(props.default).1.to_string()}
|
||||
oninput={parse_number_input}
|
||||
/>
|
||||
</OptionalField>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use yew::{Callback, Children, Html, MouseEvent, Properties, classes, function_component, html};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct OptionalFieldProps {
|
||||
pub checked: bool,
|
||||
pub children: Children,
|
||||
pub label: String,
|
||||
pub on_check: Callback<MouseEvent>,
|
||||
|
||||
#[prop_or(String::from("section"))]
|
||||
pub class: String,
|
||||
|
||||
#[prop_or_default]
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
#[function_component(OptionalField)]
|
||||
pub fn optional_field(props: &OptionalFieldProps) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<label id={format!("{}-label", props.label)} />
|
||||
<div
|
||||
class={classes!(props.class.clone(), props.checked.then_some("is-default-value"))}
|
||||
>
|
||||
{ props.children.clone() }
|
||||
if props.checked {
|
||||
<span
|
||||
class="reset-default-style"
|
||||
onclick={props.on_check.clone()}
|
||||
id={format!("{}-checkbox", props.label)}
|
||||
/>
|
||||
} else {
|
||||
<span
|
||||
class="reset-default-style-disabled"
|
||||
id={format!("{}-checkbox", props.label)}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use itertools::Itertools;
|
||||
use strum::IntoEnumIterator;
|
||||
use yew::{Callback, Properties, function_component, html};
|
||||
|
||||
use crate::components::containers::select::{Select, SelectItem};
|
||||
use crate::components::form::optional_field::OptionalField;
|
||||
|
||||
#[derive(Properties, Debug, PartialEq, Clone)]
|
||||
pub struct SelectEnumFieldProps<T>
|
||||
where
|
||||
T: IntoEnumIterator + Display + Default + PartialEq + Clone + 'static,
|
||||
{
|
||||
pub current_value: Option<T>,
|
||||
pub label: String,
|
||||
pub on_change: Callback<Option<T>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub default_value: Option<T>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
#[function_component(SelectEnumField)]
|
||||
pub fn select_enum_field<T>(props: &SelectEnumFieldProps<T>) -> yew::Html
|
||||
where
|
||||
T: IntoEnumIterator + Debug + Display + Default + PartialEq + Clone + 'static,
|
||||
{
|
||||
let values = yew::use_memo((), |_| T::iter().map(SelectItem::Option).collect_vec());
|
||||
let selected = props.current_value.clone().unwrap_or_default();
|
||||
let checked = selected != props.default_value.clone().unwrap_or_default();
|
||||
let reset_value = props.default_value.clone();
|
||||
html! {
|
||||
<div class="row">
|
||||
<OptionalField
|
||||
label={props.label.clone()}
|
||||
on_check={props.on_change.reform(move |_| reset_value.clone())}
|
||||
{checked}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<Select<T> {values} {selected} on_select={props.on_change.reform(Option::Some)} />
|
||||
</OptionalField>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::rc::Rc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use yew::{Callback, Properties, function_component, html};
|
||||
|
||||
use crate::components::containers::select::{Select, SelectItem};
|
||||
use crate::components::form::optional_field::OptionalField;
|
||||
|
||||
#[derive(Properties, Debug, PartialEq, Clone)]
|
||||
pub struct SelectValueFieldProps<T>
|
||||
where
|
||||
T: Display + PartialEq + Clone + 'static,
|
||||
{
|
||||
pub current_value: Option<T>,
|
||||
pub default_value: T,
|
||||
pub label: String,
|
||||
pub on_change: Callback<Option<T>>,
|
||||
pub values: Rc<Vec<T>>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
#[function_component(SelectValueField)]
|
||||
pub fn select_value_field<T>(props: &SelectValueFieldProps<T>) -> yew::Html
|
||||
where
|
||||
T: Display + PartialEq + Clone + 'static,
|
||||
{
|
||||
let values = yew::use_memo(props.values.clone(), |values| {
|
||||
values.iter().cloned().map(SelectItem::Option).collect_vec()
|
||||
});
|
||||
|
||||
let selected = props
|
||||
.current_value
|
||||
.clone()
|
||||
.unwrap_or_else(|| props.default_value.clone());
|
||||
|
||||
let checked = selected != props.default_value;
|
||||
html! {
|
||||
<div class="row">
|
||||
<OptionalField
|
||||
label={props.label.clone()}
|
||||
on_check={props.on_change.reform(|_| None)}
|
||||
{checked}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<Select<T> {values} {selected} on_select={props.on_change.reform(Option::Some)} />
|
||||
</OptionalField>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use perspective_client::config::{COMPLETIONS, CompletionItemSuggestion};
|
||||
use perspective_js::utils::ApiResult;
|
||||
use web_sys::*;
|
||||
use yew::html::ImplicitClone;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::portal::PortalModal;
|
||||
use super::style::StyleSurface;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FunctionDropDownState {
|
||||
values: Vec<CompletionItemSuggestion>,
|
||||
selected: usize,
|
||||
on_select: Option<Callback<CompletionItemSuggestion>>,
|
||||
target: Option<HtmlElement>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FunctionDropDownElement {
|
||||
state: Rc<RefCell<FunctionDropDownState>>,
|
||||
notify: Rc<PubSub<()>>,
|
||||
}
|
||||
|
||||
impl PartialEq for FunctionDropDownElement {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.state, &other.state)
|
||||
}
|
||||
}
|
||||
|
||||
impl ImplicitClone for FunctionDropDownElement {}
|
||||
|
||||
impl FunctionDropDownElement {
|
||||
pub fn reautocomplete(&self) {
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
pub fn autocomplete(
|
||||
&self,
|
||||
input: String,
|
||||
target: HtmlElement,
|
||||
callback: Callback<CompletionItemSuggestion>,
|
||||
) -> ApiResult<()> {
|
||||
let values = filter_values(&input);
|
||||
if values.is_empty() {
|
||||
self.hide()?;
|
||||
} else {
|
||||
let mut s = self.state.borrow_mut();
|
||||
s.values = values;
|
||||
s.selected = 0;
|
||||
s.on_select = Some(callback);
|
||||
s.target = Some(target);
|
||||
drop(s);
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn item_select(&self) {
|
||||
let state = self.state.borrow();
|
||||
if let Some(value) = state.values.get(state.selected)
|
||||
&& let Some(ref cb) = state.on_select
|
||||
{
|
||||
cb.emit(*value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_down(&self) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.selected += 1;
|
||||
if state.selected >= state.values.len() {
|
||||
state.selected = 0;
|
||||
}
|
||||
|
||||
drop(state);
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
pub fn item_up(&self) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if state.selected < 1 {
|
||||
state.selected = state.values.len();
|
||||
}
|
||||
|
||||
state.selected -= 1;
|
||||
drop(state);
|
||||
self.notify.emit(());
|
||||
}
|
||||
|
||||
pub fn hide(&self) -> ApiResult<()> {
|
||||
self.state.borrow_mut().target = None;
|
||||
self.notify.emit(());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct FunctionDropDownPortalProps {
|
||||
pub element: FunctionDropDownElement,
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
pub struct FunctionDropDownPortal {
|
||||
_sub: Subscription,
|
||||
}
|
||||
|
||||
impl Component for FunctionDropDownPortal {
|
||||
type Message = ();
|
||||
type Properties = FunctionDropDownPortalProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let link = ctx.link().clone();
|
||||
let sub = ctx
|
||||
.props()
|
||||
.element
|
||||
.notify
|
||||
.add_listener(move |()| link.send_message(()));
|
||||
Self { _sub: sub }
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let state = ctx.props().element.state.borrow();
|
||||
let target = state.target.clone();
|
||||
let on_close = {
|
||||
let element = ctx.props().element.clone();
|
||||
Callback::from(move |()| {
|
||||
let _ = element.hide();
|
||||
})
|
||||
};
|
||||
|
||||
if target.is_some() {
|
||||
let values = state.values.clone();
|
||||
let selected = state.selected;
|
||||
let on_select = state.on_select.clone();
|
||||
drop(state);
|
||||
|
||||
html! {
|
||||
<PortalModal
|
||||
tag_name="perspective-dropdown"
|
||||
surface={StyleSurface::FunctionDropdown}
|
||||
{target}
|
||||
own_focus=false
|
||||
{on_close}
|
||||
theme={ctx.props().theme.clone()}
|
||||
>
|
||||
<FunctionDropDownView {values} {selected} {on_select} />
|
||||
</PortalModal>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct FunctionDropDownViewProps {
|
||||
values: Vec<CompletionItemSuggestion>,
|
||||
selected: usize,
|
||||
on_select: Option<Callback<CompletionItemSuggestion>>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn FunctionDropDownView(props: &FunctionDropDownViewProps) -> Html {
|
||||
let body = html! {
|
||||
if !props.values.is_empty() {
|
||||
{ for props.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, value)| {
|
||||
let click = props.on_select.as_ref().unwrap().reform({
|
||||
let value = *value;
|
||||
move |_: MouseEvent| value
|
||||
});
|
||||
|
||||
html! {
|
||||
if idx == props.selected {
|
||||
<div onmousedown={click} class="selected">
|
||||
<span style="font-weight:500">{ value.label }</span>
|
||||
<br/>
|
||||
<span style="padding-left:12px">{ value.documentation }</span>
|
||||
</div>
|
||||
} else {
|
||||
<div onmousedown={click}>
|
||||
<span style="font-weight:500">{ value.label }</span>
|
||||
<br/>
|
||||
<span style="padding-left:12px">{ value.documentation }</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}) }
|
||||
}
|
||||
};
|
||||
|
||||
html! { <>{ body }</> }
|
||||
}
|
||||
|
||||
fn filter_values(input: &str) -> Vec<CompletionItemSuggestion> {
|
||||
let input = input.to_lowercase();
|
||||
COMPLETIONS
|
||||
.iter()
|
||||
.filter(|x| x.label.to_lowercase().starts_with(&input))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yew::prelude::*;
|
||||
|
||||
use super::render_warning::RenderWarning;
|
||||
use super::status_bar::StatusBar;
|
||||
use crate::presentation::{Presentation, PresentationProps};
|
||||
use crate::renderer::*;
|
||||
use crate::session::{Session, SessionProps};
|
||||
use crate::tasks::dismiss_render_warning_callback;
|
||||
|
||||
#[derive(Clone, Properties)]
|
||||
pub struct MainPanelProps {
|
||||
pub on_settings: Callback<()>,
|
||||
|
||||
/// Reset callback forwarded from the root component. Fired when the user
|
||||
/// clicks the reset button; `bool` is `true` for a full reset (expressions
|
||||
/// + column configs), `false` for config-only.
|
||||
pub on_reset: Callback<bool>,
|
||||
|
||||
/// Snapshots threaded from root. Read for `has_table`, `title` here in
|
||||
/// the panel itself; threaded wholesale to `StatusBar`/`StatusIndicator`.
|
||||
pub session_props: SessionProps,
|
||||
pub renderer_props: RendererProps,
|
||||
pub presentation_props: PresentationProps,
|
||||
|
||||
/// Derived from root: `settings_open && has_table_loaded`.
|
||||
pub is_settings_open: bool,
|
||||
|
||||
/// Root-managed in-flight render counter (not engine state).
|
||||
pub update_count: u32,
|
||||
|
||||
/// State
|
||||
pub session: Session,
|
||||
pub renderer: Renderer,
|
||||
pub presentation: Presentation,
|
||||
}
|
||||
|
||||
impl PartialEq for MainPanelProps {
|
||||
fn eq(&self, rhs: &Self) -> bool {
|
||||
self.session_props == rhs.session_props
|
||||
&& self.renderer_props == rhs.renderer_props
|
||||
&& self.presentation_props == rhs.presentation_props
|
||||
&& self.is_settings_open == rhs.is_settings_open
|
||||
&& self.update_count == rhs.update_count
|
||||
}
|
||||
}
|
||||
|
||||
impl MainPanelProps {
|
||||
fn is_title(&self) -> bool {
|
||||
self.session_props.title.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MainPanelMsg {
|
||||
PointerEvent(web_sys::PointerEvent),
|
||||
}
|
||||
|
||||
pub struct MainPanel {
|
||||
main_panel_ref: NodeRef,
|
||||
}
|
||||
|
||||
impl Component for MainPanel {
|
||||
type Message = MainPanelMsg;
|
||||
type Properties = MainPanelProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
main_panel_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
MainPanelMsg::PointerEvent(event) => {
|
||||
if event.target().map(JsValue::from)
|
||||
== self
|
||||
.main_panel_ref
|
||||
.cast::<web_sys::HtmlElement>()
|
||||
.map(JsValue::from)
|
||||
{
|
||||
ctx.props().presentation.statusbar_pointer_event.emit(event);
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let Self::Properties {
|
||||
presentation,
|
||||
renderer,
|
||||
session,
|
||||
..
|
||||
} = ctx.props();
|
||||
|
||||
let is_settings_open = ctx.props().is_settings_open;
|
||||
let on_settings = (!is_settings_open).then(|| ctx.props().on_settings.clone());
|
||||
|
||||
let mut class = classes!();
|
||||
if !is_settings_open {
|
||||
class.push("settings-closed");
|
||||
}
|
||||
|
||||
if ctx.props().is_title() {
|
||||
class.push("titled");
|
||||
}
|
||||
|
||||
let pointerdown = ctx.link().callback(MainPanelMsg::PointerEvent);
|
||||
let on_dismiss_warning = dismiss_render_warning_callback(session, renderer);
|
||||
|
||||
html! {
|
||||
<div id="main_column">
|
||||
<StatusBar
|
||||
id="status_bar"
|
||||
{on_settings}
|
||||
on_reset={ctx.props().on_reset.clone()}
|
||||
session_props={ctx.props().session_props.clone()}
|
||||
presentation_props={ctx.props().presentation_props.clone()}
|
||||
is_settings_open={ctx.props().is_settings_open}
|
||||
update_count={ctx.props().update_count}
|
||||
{presentation}
|
||||
{renderer}
|
||||
{session}
|
||||
/>
|
||||
<div
|
||||
id="main_panel_container"
|
||||
ref={self.main_panel_ref.clone()}
|
||||
{class}
|
||||
onpointerdown={pointerdown}
|
||||
>
|
||||
<RenderWarning
|
||||
on_dismiss={on_dismiss_warning}
|
||||
dimensions={ctx.props().renderer_props.render_limits}
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy(&mut self, _ctx: &Context<Self>) {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user