Make the data model use Item and List types universally, with nodes authored as rank-polymorphic kernels (#4335)
* Add rank polymorphism node audit classifying all 271 nodes
* Implement StaticType for Item<T>
* Generate Item and mapped List wire variants for nodes declaring an Item<T> primary input
* Migrate nine nodes to Item element-wise kernels, dissolving the blending trait boilerplate
* Document the Item kernel implementation and staging plan
* Route Item<Vector> through TaggedValue::TypeDefault
* Add executor integration tests covering the Item and List wire variants
* Collapse element-wise Item/List wire pairs to the List form for conversion insertion
* Migrate sixteen vector modifier nodes to Item element-wise kernels
* Migrate Sample Image, Extend Image to Bounds, and Dehaze to Item element-wise kernels
* Fix bevel_with_transform test to actually exercise the transform attribute
* Implement From<T> for Item<T>
* Register PromoteNode rank adapters wrapping bare values into Item wires
* Insert PromoteNode adapters for Item/List wire pair fields in the preprocessor
* Define a real promote node backing the PromoteNode registry identifiers
* Zip ranked Item connectors by frame slot in the mapped element-wise variant
* Register ItemToListNode singleton raise adapters
* Resolve Item wires against List connectors by inserting promotion adapters at construction
* Rank the Offset Points distance connector and prove mixed-rank resolution end-to-end
* Implement Clampable for Item and List wires with per-variant clamp bounds
* Rank the Round Corners radius connector, exercising hard bounds on a ranked wire
* Implement ApplyTransform for Item
* Add Item wire implementations to the Transform node, keeping rank-0 chains rank 0
* Detect element-wise nodes by lazy primary connectors declaring Output = Item
* Convert Transform to an Item kernel with ranked parameters, delivering the broadcast milestone
* Rename Apply Transform to Bake Transform, baking item transforms on Vector, DAffine2, and DVec2
* Promote bare wires onto Item connectors at resolution via WrapItemNode adapters
* Rank the numeric, vector, and boolean parameters across the migrated element-wise nodes
* Rank the enum, integer, and seed parameters, registering their rank adapters via a consolidated macro
* Amend the audit with the DashPattern value type resolution
* Migrate the string family to Item element-wise kernels
* Unwrap Item wires into bare legacy connectors at resolution via UnwrapItemNode adapters
* Shadow owned node parameters in bodies instead of mut in signatures
* Migrate the math family and string measure nodes to Item element-wise kernels
* Convert the comparison and clamp nodes to Item kernels, dropping unreachable &str rows
* Flat-map expander kernels returning List under the mapped variant's frame
* Migrate the expander nodes to Item kernels flat-mapping under the frame
* Remove the unused peel_list helper
* Rank the raster adjustment and blending kernels, recontextualizing shader nodes onto an Item stand-in
Migrate the 16 adjustment nodes, Mix, Color Overlay, and Gradient Map from whole-List kernels to rank-0 Item kernels, letting the macro derive the List-mapped (zip) variants. Move the Adjust and Blend per-element seams off List onto the element types (add the Raster<CPU> impls, drop the now-dead List impls).
Shader nodes keep their bodies verbatim: PerPixelAdjust re-emits the identical kernel against a transparent no_std Item stand-in, so every Item<T> connector and .element() call resolves to a zero-cost identity on the GPU while the uniform buffer stays bare repr(C). The macro peels Item off ranked uniform params, wraps the fetched texel and uniforms at the entry point, and unwraps the result. This drops the shader_node/Item incompatibility guard. Register rank adapters for the adjustment enums.
* Update the rank polymorphism roadmap for the landed shader-node and adjustments chunk
* Rename the GPU Item stand-in to ShaderItem, aliased as Item at its shader-node import sites
* Flip the vector shape generators to emit rank-0 Item<Vector>
The shape generators (Rectangle, Circle, Ellipse, Arc, Spiral, Polygon, Star, Arrow, Line, Grid, QR Code) each produced exactly one shape wrapped in a singleton List<Vector>. Emit Item<Vector> directly so they connect to the rank-0 content connector of the migrated Transform node. Downstream List consumers receive the value through the existing Item to List promotion.
Relax the element-wise validation so a `()` (generator) primary may return Item<T> without being element-wise. Adapt the Repeat on Points test, which still takes a List content connector, by raising the generator's Item output through a singleton wrapper node.
* Parse ranked Item<T> parameter defaults against the bare element type
A ranked `Item<T>` parameter's default value is a bare, unranked `T` (promoted to the wire at resolution), but the preprocessor was handed the wrapped `Item<T>` type and could not parse the literal, flooding the console with warnings and dropping the defaults. Key the field's default_type metadata off the peeled element type for concrete ranked parameters, leaving generic `Item<T>` primaries and skip_impl nodes untouched.
* Parse an element-wise primary's scalar default against the bare element type
An element-wise node's primary reports its default_type as the List wire form so an unconnected primary defaults to an empty list. But when the primary carries a scalar `#[default]` (such as Root's radicand), that literal must parse as a bare element, not a List. Key the primary's default_type off the bare element type when it has a Default value source, keeping the List form otherwise.
* Add the DashPattern value type for stroke dash sequences
Introduce a rank-0 DashPattern value type (a Vec<f64> of alternating dash and gap lengths) so a stroke's dash pattern is a single frameable value rather than a rank-1 List<f64>. Register it as an auto-generated TaggedValue variant, parse its default from a comma or space separated string, and register its rank adapters. Not yet wired into the Stroke node.
* Rank the Fill and Stroke nodes element-wise and give Stroke a DashPattern connector
Migrate Fill and Stroke to element-wise Item<V> primaries (over Vector and Graphic element types) via a new element-level VectorItemMut trait, so styling one shape yields one shape and rank is preserved instead of promoting the input to a singleton List and emitting a List. The macro derives the List-mapped variant for genuine collections.
Wire the Stroke dash sequence to the new rank-0 DashPattern value type, collapsing the old content x paint x dash cartesian and dropping the IntoF64Vec trait. Update the stroke properties dash widget, the drawing tool, and graph-operation plumbing to read and write DashPattern, and migrate legacy F64Array, F64, and String dash inputs on document open.
Assign Colors stays a whole-collection node: each element's gradient position depends on its index among all siblings, which the element frame does not expose, so it keeps its List primary and the VectorListIterMut trait.
* Register rank adapters for the ranked Stroke enum parameters
The element-wise Stroke node ranks its align, cap, and paint order parameters as Item<StrokeAlign>, Item<StrokeCap>, and Item<PaintOrder>, but those enums lacked promotion adapters, so a bare default enum value could not be promoted to its Item wire and no Stroke variant resolved ("No construct found for node"). Register their rank adapters alongside StrokeJoin.
* Display Item wires in the Data panel without a List's ID column
Add a TableItemLayout impl for Item<T> and recognize Item wire types when introspecting graph data. An Item holds a single element, so it renders as a one-row table of the element plus its attributes with no leading index column, and it labels as its element type T rather than a List's T[]. Add ItemAttributeValues::get_any for the attribute widget dispatch.
* Register MonitorNode for Item wire types so the Data panel introspects them directly
Graph introspection wraps the inspected output in a generic MonitorNode typed to the wire. Without Item<T> monitor registrations, an Item<Vector> output could only be monitored after an Item to List promotion, so the Data panel captured and displayed a List<Vector> despite the connector being Item<Vector>. Register monitors for the Item types the element-wise nodes emit, and add the matching Data panel downcast entries.
* Color and double Item/List wires and cleave layer-stack connectors in the node graph
* Route wire color and rank through hidden nodes and refresh them on type changes
* Rework the DashPattern connector conversions with element-wise promotion and an explicit reducer node
* Rank the remaining value, context, aggregation, and transform nodes onto Item<T> wires
* Back DashPattern with a List<f64> so the Data panel can introspect its lengths
* Carry a single Item<T> through varargs so the Read context nodes emit Item<T> not List<T>
* Relax rank validation for aggregation shapes, add element adapters, and match variants by fewest promotions
* Rank the remaining bare and unnecessarily-List connectors across the node catalog
* Add Graphic::None and the FillChoice paint value, making colors and gradients plain values
* Rename GradientStops to Gradient and the legacy Gradient/Fill structs to LegacyGradient/LegacyFill
* Restore generator frame-from-params ranking to the roadmap as a planned stage
* Rename the ranked-field adapter identifier from PromoteNode to FieldAdapterNode to reflect its full contract
* Unload only the wires whose displayed style changed when types update
* Peel wire rank in the editor's semantic type checks so rank-0 layers are recognized
* Restore the whole-List Transform variant so rank-1 content wires resolve again
* Register the Item wire forms for the Memoize and Context Modification infrastructure nodes
* Give every ranked connector a field adapter and add numeric cast variants for legacy wires
* Key a ranked param's type default off its Item wire form when no literal default exists
* Inherit the layer's content value when splicing a node into an empty chain
* Migrate stale List-form TypeDefault inputs to the definition's current default
* Generate the mapped wire variant only when the element-wise node has a frame source
* Let a bare wire feed a List connector via a wrap-raise adapter, costed as two rank steps
* Add a zip companion to the whole-List Transform so ranked List parameters pair per slot
* Add the Sum, Average, Minimum, Maximum, Any, and All list reducers
* Convert the measure family to element-wise Item kernels per the audit classification
* Prefer the bare element value over the Item type default so ranked params keep their widgets
* Rename GradientStopsUI to GradientUI
* Split Fill's optional transform into a _has_transform bool and a ranked _transform matrix
* Rename the migration-only OptionalDAffine2 TaggedValue to LegacyOptionalDAffine2
* Flow byte buffers as Item<Resource> instead of List<u8> across the byte nodes
* Macro-generate the list-content wire variant, retiring the hand-written Transform-zip, Area, and Centroid companions
* Let ()-primary generators take ranked params and frame over them via the mapped variant, ranking Circle's radius
* Rank the vector shape generators' params to Item, adding a rank-aware input grab to the introspection harness
* Rank the value, color, and text generator params to Item
* Rank the raster, web-request, and context-reader generator params to Item
* Fix the repeat and brush test wirings left behind by the param-ranking sweeps
* Delete the vestigial Some, Unwrap Option, and Size Of debug nodes
* Delete the Attach Attribute node, folding its role into Write Attribute
* Add the Filter and Sort list companion nodes
* Guard the removed-definition migration swap target with a test
* Add the Box Corners value type in place of the rectangle corner radius list
* Split Text to Vector's per-glyph mode into a Text to Vector Glyphs node
* Rank the Combine Channels node's channel connectors to Item
* Make Map Points an element-wise node
* Delete the deprecated Upload Texture node
* Update the implementation roadmap to reflect the landed stages
* Let monitor introspection read rank-0 wires, locking in the layer coercion promotion path
* Prefer the rank-0 default when disconnecting a rank-capable input
* Make Path Modify an element-wise node
* Wrap node paths in a NodeIdPath newtype so they flow as a single Item
* Give Item<Raster<CPU>> a default so an unconnected Brush background resolves
* Stop the Brush node from setting layer attributes its paint operation doesn't produce
* Present-gate Flatten Path's adopted layer path like its fill and stroke
* Gate carried layer attributes on static column presence, not runtime values
* Give the remaining graphic Item<T> types a default so unconnected primaries resolve
* Dispatch a ranked param's Properties widget from its rank-0 element type
* Make Extract Transform an element-wise node, restoring the Origins to Polyline body
* Rename Flatten Path to Combine Paths
* Stamp Legacy Layer Extend's adopted layer path as a readable NodeIdPath
* Drop the dead List<u8> and List<NodeId> wire rows
* Rank Flatten Graphic's Fully Flatten toggle to Item
* Update the implementation roadmap with the endgame scope
* Make Combine Paths a reducer that collapses the whole frame into one path
* Stop type-converter nodes from carrying the source's unrelated attributes
* Format the Origins to Polyline regression test
* Wrap the Brush node's trace in a BrushTrace newtype so it flows as one value
* Make Switch a framed element-wise select, bundling whole collections
* Widen and align element-type coverage across the list and graphic nodes
* Register the compiler's cache chain pair for every ranked enum and newtype wire
* Fix wire colors for Passthrough outputs, bundled lists, and bools, and widen list wires
* Represent List wire types structurally with Type::List, replacing name-parsed rank promotion
* Treat scope and data fields as environment, rank scope wires as Item, and feed the render boundary through a context vararg
* Delete the vestigial Clone debug node
* Reinstate Upload Texture as an element-wise node and fix the GPU variants' scope executor and rank adapters
* Rename Combine Paths back to Flatten Path, deferring that rename to its own PR
* Deduplicate the promotion adapter registrations into the field adapter macro
* Rank Write Attribute's value connector to Item<AttributeValueDyn>, retiring the UnwrapItem bridge
* Vertical wire styling
* Store the editor layer path attribute as a bare NodeIdPath, not an Item<NodeIdPath>
* Rank Context Modification's features connector to Item<ContextFeatures>, dropping the dead memoize row
* Rank Path Modify's modification parameter to Item<Box<VectorModification>>
* Rename the field adapter node family to input adapter
* Drop the dead bare scalar rows from Context Modification's implementations list
* Move the dynamic executor's test module into its own file
* Drop the registry's unreachable bare rows for Memoize, the cache chain, and ConvertNode
* Materialize stored TaggedValues as ranked Item wires at the source
* Remove the bare-wire promotion and adapter machinery made dead by ranked value materialization
* Plant the input adapter for List-only inputs, composing position conversion from standard rows
* Consolidate Into/Convert conversions into the input adapter umbrella and rename the rank adapter identifiers
* Fix grouped layers gaining a phantom None stack element from the FillChoice default hijacking every List<Graphic> disconnect
* Enforce ranked node inputs in the macro, rejecting bare wire declarations
* Remove the unit Context => () machinery rows, leaving () purely as the no-primary sentinel
* Add a --signatures rank-audit mode to node-docs for the ranked-wire migration
* Remove the node-docs --signatures rank-audit mode now that ranked wires are enforced
* Migrate legacy no-color values on the Black & White, Color Overlay, and Empty Image color inputs
* Rewrite the element-wise accessor wire type at the primary input, not raw index 0
* Register the cache chain for Resource wires, replacing the lone hand-written Monitor row
* Gate the remaining Raster<GPU> registry rows behind the gpu feature
* Let List<DVec2> wires erase to ListDyn for the attribute reader and element counter
* Rename Extract Element to Item at Index, Count Elements to List Length, and Omit Element to Remove at Index
* Store paint picks as plain color/gradient values, removing the FillChoice value type
* Code review restructuring
* Sort by the consumed sort_key attribute or natural element order, adding the Sort Key node
* Remove the new list-combinator and reducer nodes to defer them to a follow-up PR
* Parse Fill and Stroke color defaults through the paint wire's Graphic element
* Emit ranked implementation-row default types structurally so their element TypeIds survive to default-literal parsing
* Exempt the deliberate no-paint choice from the stale List-form TypeDefault migration
* Migrate the legacy 4-input Fill directly to the split has-transform shape
* Upgrade the demo artwork
* Fix the valid AI review findings: Item eq/hash contract, table-era no-paint migration, quantize List rows, and other smaller issues
* Remove the rank polymorphism working documents
* Hash Item attribute values directly instead of debug-formatting them, speeding up cached evaluation
* Replace the data panel's dead bare-wire downcast arms with full coverage of the ranked monitor row types
* Derive PartialEq for Item now that attributes participate in equality
* Extend the data panel's attribute dispatchers with the newly supported scalar and choice enum types
* Add List monitor rows for the framed numeric conversion outputs so inspecting them resolves, with matching data panel arms
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
wrapper/pkg/
|
||||
wrapper/pkg-native/
|
||||
public/build/
|
||||
dist/
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"useTabs": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 200,
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.svelte"
|
||||
],
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Overview of `/frontend/`
|
||||
|
||||
The Graphite frontend is a web app that provides the presentation for the editor. It displays the GUI based on state from the backend and provides users with interactive widgets that send updates to the backend, which is the source of truth for state information. The frontend is built out of reactive components using the [Svelte](https://svelte.dev/) framework. The backend is written in Rust and compiled to WebAssembly (Wasm) to be run in the browser alongside the JS code.
|
||||
|
||||
## Bundled assets: `assets/`
|
||||
|
||||
Images that are used in components and embedded into the application bundle by the build system.
|
||||
|
||||
## Svelte/TypeScript source: `src/`
|
||||
|
||||
Source code for the web app in the form of Svelte components and [TypeScript](https://www.typescriptlang.org/) files.
|
||||
|
||||
## Editor wrapper: `wrapper/`
|
||||
|
||||
Wraps the editor backend codebase (`/editor`) and provides a JS-centric API for the web app to use as an entry point, unburdened by Rust's complex data types that are incompatible with JS data types. Bindings (JS functions that call into the Wasm module) are provided by `wasm-bindgen`. As part of `cargo run`, our build tool compiles this crate to Wasm, runs the `wasm-bindgen` CLI to generate the JS/TS bindings in `wrapper/pkg/`, and (for release builds) optimizes the binary with Binaryen's `wasm-opt`.
|
||||
|
||||
## ESLint configuration: `eslint.config.js`
|
||||
|
||||
When you use `npm run check`, [ESLint](https://eslint.org/) checks the code in the frontend project for code quality. (The command also reports TS and Svelte errors.) The tool enforces style rules on the JS, TS, and Svelte (including its HTML and SCSS) files in our frontend codebase. As it is set up in this config file, ESLint will complain about bad practices and often help reformat code automatically when the file is saved in VS Code, or manually when `npm run fix` is executed. (If you don't use VS Code, remember to run this command before committing!) This config file for ESLint sets our style preferences and configures our usage of extensions/plugins for Svelte support and [Prettier](https://prettier.io/)'s role as a code formatter.
|
||||
|
||||
## Svelte configuration: `svelte.config.js`
|
||||
|
||||
Configures the Svelte compiler, including the preprocessor setup for SCSS and TypeScript support, and compiler warning filters.
|
||||
|
||||
## TypeScript configuration: `tsconfig.json`
|
||||
|
||||
Basic configuration options for the TypeScript build tool to do its job in our repository.
|
||||
|
||||
## Vite configuration: `vite.config.ts`
|
||||
|
||||
We use the [Vite](https://vitejs.dev/) bundler/build system. This file is where we configure Vite to set up plugins (like the third-party license checker/generator). Part of the license checker plugin setup includes some functions to format web package licenses, as well as Rust package licenses provided by [cargo-about](https://github.com/EmbarkStudios/cargo-about), into a text file that's distributed with the application to provide license notices for third-party code.
|
||||
|
||||
## npm ecosystem packages: `package.json`
|
||||
|
||||
While we don't use Node.js as a JS-based server, we do rely on its ecosystem of packages for our build system toolchain. Our project's philosophy on third-party packages is to keep our dependency tree as light as possible, so adding anything new to our `package.json` should have overwhelming justification. Most of the packages are just development tooling (TypeScript, Vite, ESLint, Prettier, Sass, etc.) that run in your terminal during the build process.
|
||||
|
||||
## npm package installed versions: `package-lock.json`
|
||||
|
||||
Specifies the exact versions of packages installed in the npm dependency tree. While `package.json` specifies which packages to install and their minimum/maximum acceptable version numbers, `package-lock.json` represents the exact versions of each dependency and sub-dependency. Running `npm ci` will grab these exact versions to ensure you are using the same packages as everyone else working on Graphite. `npm update` will modify `package-lock.json` to specify newer versions of any updated (sub-)dependencies and download those, as long as they don't exceed the maximum version allowed in `package.json`. To check for newer versions that exceed the max version, run `npm outdated` to see a list. Unless you know why you are doing it, try to avoid committing updates to `package-lock.json` by mistake if your code changes don't pertain to package updates. And never manually modify the file.
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,140 @@
|
||||
import js from "@eslint/js";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import * as pluginImport from "eslint-plugin-import";
|
||||
import pluginPrettier from "eslint-plugin-prettier";
|
||||
import pluginSvelte from "eslint-plugin-svelte";
|
||||
import globals from "globals";
|
||||
import ts from "typescript-eslint";
|
||||
|
||||
const importOrder = {
|
||||
alphabetize: { order: "asc", caseInsensitive: true },
|
||||
"newlines-between": "never",
|
||||
warnOnUnassignedImports: true,
|
||||
pathGroups: [
|
||||
{ pattern: "/**", group: "internal" },
|
||||
{ pattern: "/../**", group: "internal" },
|
||||
],
|
||||
pathGroupsExcludedImportTypes: [],
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended,
|
||||
pluginImport.flatConfigs.recommended,
|
||||
pluginImport.flatConfigs.typescript,
|
||||
pluginSvelte.configs["flat/recommended"],
|
||||
pluginSvelte.configs["flat/prettier"],
|
||||
globalIgnores([
|
||||
// Ignore generated directories
|
||||
"**/node_modules/",
|
||||
"**/dist/",
|
||||
"**/pkg/",
|
||||
"**/pkg-native/",
|
||||
"wasm/pkg/",
|
||||
// Don't ignore JS and TS dotfiles in this folder
|
||||
"!**/.*.js",
|
||||
"!**/.*.ts",
|
||||
]),
|
||||
{
|
||||
plugins: {
|
||||
prettier: pluginPrettier,
|
||||
},
|
||||
settings: {
|
||||
"import/parsers": { "@typescript-eslint/parser": [".ts"] },
|
||||
"import/resolver": { typescript: true, node: true },
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Standard ESLint config (for ordinary JS syntax linting)
|
||||
indent: "off",
|
||||
quotes: ["error", "double", { allowTemplateLiterals: true }],
|
||||
camelcase: ["error", { properties: "always" }],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"eol-last": ["error", "always"],
|
||||
"max-len": ["error", { code: 200, tabWidth: 4, ignorePattern: `d="([\\s\\S]*?)"` }],
|
||||
"prefer-destructuring": "off",
|
||||
"no-console": "warn",
|
||||
"no-debugger": "warn",
|
||||
"no-param-reassign": ["error", { props: false }],
|
||||
"no-bitwise": "off",
|
||||
"no-shadow": "off",
|
||||
"no-use-before-define": "off",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{ group: ["./**", "../**"], message: "\nImports must be absolute (e.g. '/src/<subpath>') not relative (e.g. './<subpath>')." },
|
||||
{ group: ["src/**", "assets/**", "wrapper/**"], message: "\nLocal imports must start with '/' (e.g. '/src/<subpath>' not 'src/<subpath>')." },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// TypeScript plugin config (for TS-specific linting)
|
||||
"@typescript-eslint/indent": "off",
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
args: "all",
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrors: "all",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
destructuredArrayIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": "error",
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
|
||||
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
|
||||
"@typescript-eslint/consistent-indexed-object-style": ["error", "record"],
|
||||
"@typescript-eslint/consistent-generic-constructors": ["error", "constructor"],
|
||||
"@typescript-eslint/no-restricted-types": ["error", { types: { null: "Use `undefined` instead." } }],
|
||||
|
||||
// Prettier plugin config (for validating and fixing formatting)
|
||||
"prettier/prettier": "error",
|
||||
|
||||
// Svelte plugin config (for validating Svelte-specific syntax)
|
||||
"svelte/no-at-html-tags": "off",
|
||||
"svelte/no-useless-mustaches": "off",
|
||||
"svelte/valid-compile": ["error", { ignoreWarnings: true }],
|
||||
"svelte/require-each-key": "off", // TODO: Remove this rule and fix the places where it's violated
|
||||
|
||||
// Import plugin config (for intelligently validating module import statements)
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"import/no-unresolved": "error",
|
||||
// `no-duplicates` disabled due to <https://github.com/import-js/eslint-plugin-import/issues/1479#issuecomment-1789527447>. Reenable if that issue gets fixed.
|
||||
"import/no-duplicates": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"import/no-relative-packages": "error",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/order": ["error", importOrder],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/icons.ts"],
|
||||
rules: { "import/order": ["error", { ...importOrder, "newlines-between": "ignore" }] },
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
languageOptions: {
|
||||
// Parse the `<script>` in `.svelte` as TypeScript by adding the following configuration.
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
parser: ts.parser,
|
||||
extraFileExtensions: [".svelte"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
extends: [ts.configs.disableTypeChecked],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Graphite</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
|
||||
<link rel="manifest" href="site.webmanifest" />
|
||||
<link rel="mask-icon" href="safari-pinned-tab.svg" color="#473a3a" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=0.5, maximum-scale=0.5, minimum-scale=0.5" />
|
||||
<meta name="apple-mobile-web-app-title" content="Graphite" />
|
||||
<meta name="application-name" content="Graphite" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="theme-color" content="#222222" />
|
||||
<meta name="color-scheme" content="dark only" />
|
||||
<meta name="darkreader-lock" />
|
||||
<meta name="description" content="Graphite is an open source vector graphics editor and procedural design engine. Create and animate with a nondestructive editing workflow that combines layer-based compositing with node-based generative design." />
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<!-- INDEX_HTML_HEAD_REPLACEMENT -->
|
||||
</head>
|
||||
<body>
|
||||
<noscript>JavaScript is required</noscript>
|
||||
<style>
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #eee;
|
||||
border-color: #eee transparent #eee transparent;
|
||||
animation: spinning-loading-indicator 1s linear infinite;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
border: none;
|
||||
animation: none;
|
||||
content: "Loading…";
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinning-loading-indicator {
|
||||
0% {
|
||||
transform: translate(-30px, -30px) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-30px, -30px) rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "graphite-web-frontend",
|
||||
"private": true,
|
||||
"description": "Graphite's web app frontend.",
|
||||
"author": "Graphite Authors <contact@graphite.art>",
|
||||
"browserslist": "> 1.5%, last 2 versions, not dead, not ie 11, not op_mini all, not ios_saf < 13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "svelte-check --fail-on-warnings && eslint",
|
||||
"fix": "eslint --fix"
|
||||
},
|
||||
"//": "NOTE: `source-sans-pro` is never to be upgraded to 3.x because that renders 1px above its intended position.",
|
||||
"///": "Waiting on <https://github.com/import-js/eslint-plugin-import/issues/3227> before we can update @eslint/js and eslint to 10.x",
|
||||
"dependencies": {
|
||||
"source-code-pro": "2.42.0",
|
||||
"source-sans-pro": "2.45.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-svelte": "^3.17.0",
|
||||
"globals": "^17.4.0",
|
||||
"license-checker-rseidelsohn": "^4.4.2",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"sass": "^1.99.0",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.3"
|
||||
},
|
||||
"homepage": "https://graphite.art",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/GraphiteEditor/Graphite.git"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import Editor from "/src/components/Editor.svelte";
|
||||
import { createSubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import type { MessageName, SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { loadDemoArtwork } from "/src/utility-functions/network";
|
||||
import { operatingSystem } from "/src/utility-functions/platform";
|
||||
import { initWasm } from "/src/utility-functions/wasm-loader";
|
||||
import { EditorWrapper, receiveNativeMessage } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import type { FrontendMessage } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let subscriptions: SubscriptionsRouter | undefined = undefined;
|
||||
let editor: EditorWrapper | undefined = undefined;
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize the editor wrapper
|
||||
const wrapper = await initWasm();
|
||||
for (const [name, f] of Object.entries(wrapper)) {
|
||||
if (name.startsWith("__node_registry")) f();
|
||||
}
|
||||
window.imageCanvases = {};
|
||||
window.receiveNativeMessage = receiveNativeMessage;
|
||||
|
||||
// Create the editor and subscriptions router
|
||||
const randomSeed = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
|
||||
subscriptions = createSubscriptionsRouter();
|
||||
editor = await EditorWrapper.create(operatingSystem(), randomSeed, (messageType: MessageName, messageData: FrontendMessage) => {
|
||||
subscriptions?.handleFrontendMessage(messageType, messageData);
|
||||
});
|
||||
|
||||
await loadDemoArtwork(editor);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor?.free();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if subscriptions !== undefined && editor !== undefined}
|
||||
<Editor {subscriptions} {editor} />
|
||||
{/if}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Overview of `/frontend/src/`
|
||||
|
||||
## Svelte components: `components/`
|
||||
|
||||
Svelte components that build the Graphite editor GUI from layouts, panels, widgets, and menus. These each contain a TypeScript section, a Svelte-templated HTML template section, and an SCSS stylesheet section. The aim is to avoid implementing much editor business logic here, just enough to make things interactive and communicate to the backend where the real business logic should occur.
|
||||
|
||||
## Managers: `managers/`
|
||||
|
||||
TypeScript files, constructed by the editor frontend, which manage the input/output of browser APIs and link this functionality with the editor backend. These files subscribe to frontend messages to execute JS APIs, and in response to these APIs or user interactions, they may call functions in the backend (defined in `/frontend/wrapper/editor_wrapper.rs`).
|
||||
|
||||
Each manager module stores its dependencies (like `subscriptionsRouter` and `editorWrapper`) in module-level variables and exports a `create*()` and `destroy*()` function pair. `Editor.svelte` calls each `create*()` constructor in its `onMount` and calls each `destroy*()` in its `onDestroy`. Managers replace themselves during HMR updates if they are modified live during development.
|
||||
|
||||
## Stores: `stores/`
|
||||
|
||||
TypeScript files, constructed by the editor frontend, which provide reactive state to Svelte components. Each module persists a Svelte writable store at module level (surviving HMR via `import.meta.hot.data`) and exports a `create*()` function that sets up frontend message subscriptions and returns `{ subscribe }` (the shape required by Svelte's custom store contract). A corresponding `destroy*()` function is also exported. Some stores also export standalone action functions (like `createCrashDialog()` or `toggleFullscreen()`) as module-level exports.
|
||||
|
||||
In `Editor.svelte`, each store is created synchronously during component initialization (not in `onMount`, since child components need `getContext` access during their own initialization) and passed to Svelte's `setContext()`. Components access stores via calls like `getContext<DialogStore>("dialog")`. Unlike managers, stores do not replace themselves during HMR; instead, `Editor.svelte` is remounted to replace them entirely.
|
||||
|
||||
## *Managers vs. stores*
|
||||
|
||||
*Both managers and stores subscribe to frontend messages and may interact with browser APIs. The difference is that stores expose reactive state to components via `setContext()`/`getContext()`, while managers are self-contained systems that operate for the lifetime of the application and aren't accessed by Svelte components.*
|
||||
|
||||
## Utility functions: `utility-functions/`
|
||||
|
||||
TypeScript files which define and `export` individual helper functions for use elsewhere in the codebase. These files should not persist state outside each function.
|
||||
|
||||
## Subscriptions router: `subscriptions-router.ts`
|
||||
|
||||
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeFrontendMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. The router's other function, `handleFrontendMessage(messageType, messageData)`, is called via the callback passed to `EditorWrapper.create()` in `App.svelte` when the backend sends a `FrontendMessage`. When this occurs, the subscriptions router delivers the message to the subscriber by executing its registered `callback` function.
|
||||
|
||||
## Svelte app entry point: `App.svelte`
|
||||
|
||||
The entry point for the Svelte application. Initializes and creates the `EditorWrapper` backend instance and the subscriptions router and renders `Editor.svelte` once both are ready. The `EditorWrapper` is the wasm-bindgen interface to the Rust editor backend (defined in `/frontend/wrapper/editor_wrapper.rs`), providing access to callable backend functions. Both the editor and subscriptions router are passed as props to `Editor.svelte` and set as Svelte contexts for use throughout the component tree.
|
||||
|
||||
## Editor base instance: `Editor.svelte`
|
||||
|
||||
This is where we define global CSS style rules, construct all stores and managers, set store contexts for component access, and call each module's `destroy*()` function on unmount (on HMR during development).
|
||||
|
||||
## Global type augmentations: `global.d.ts`
|
||||
|
||||
Extends built-in browser type definitions using TypeScript's interface merging. This includes Graphite's custom properties on the `window` object, custom events like `pointerlockmove`, and experimental browser APIs not yet in TypeScript's standard library. New custom events or non-standard browser APIs used by the frontend should be declared here.
|
||||
|
||||
## JS bundle entry point: `main.ts`
|
||||
|
||||
The entry point for the entire project's code bundle. Mounts the Svelte application with `export default mount(App, { target: document.body })`.
|
||||
@@ -0,0 +1,400 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, setContext } from "svelte";
|
||||
import MainWindow from "/src/components/window/MainWindow.svelte";
|
||||
import { createClipboardManager, destroyClipboardManager } from "/src/managers/clipboard";
|
||||
import { createHyperlinkManager, destroyHyperlinkManager } from "/src/managers/hyperlink";
|
||||
import { createInputManager, destroyInputManager } from "/src/managers/input";
|
||||
import { createLocalizationManager, destroyLocalizationManager } from "/src/managers/localization";
|
||||
import { createPanicManager, destroyPanicManager } from "/src/managers/panic";
|
||||
import { createPersistenceManager, destroyPersistenceManager } from "/src/managers/persistence";
|
||||
import { createAppWindowStore, destroyAppWindowStore } from "/src/stores/app-window";
|
||||
import { createColorPickerStore, destroyColorPickerStore } from "/src/stores/color-picker";
|
||||
import { createDialogStore, destroyDialogStore } from "/src/stores/dialog";
|
||||
import { createDocumentStore, destroyDocumentStore } from "/src/stores/document";
|
||||
import { createFullscreenStore, destroyFullscreenStore } from "/src/stores/fullscreen";
|
||||
import { createNodeGraphStore, destroyNodeGraphStore } from "/src/stores/node-graph";
|
||||
import { createPortfolioStore, destroyPortfolioStore } from "/src/stores/portfolio";
|
||||
import { createTooltipStore, destroyTooltipStore } from "/src/stores/tooltip";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Graphite Wasm editor and subscriptions router
|
||||
export let subscriptions: SubscriptionsRouter;
|
||||
export let editor: EditorWrapper;
|
||||
setContext("subscriptions", subscriptions);
|
||||
setContext("editor", editor);
|
||||
|
||||
const stores = {
|
||||
dialog: createDialogStore(subscriptions, editor),
|
||||
tooltip: createTooltipStore(subscriptions),
|
||||
document: createDocumentStore(subscriptions),
|
||||
fullscreen: createFullscreenStore(subscriptions),
|
||||
nodeGraph: createNodeGraphStore(subscriptions),
|
||||
portfolio: createPortfolioStore(subscriptions, editor),
|
||||
appWindow: createAppWindowStore(subscriptions),
|
||||
colorPicker: createColorPickerStore(subscriptions),
|
||||
};
|
||||
Object.entries(stores).forEach(([key, store]) => setContext(key, store));
|
||||
|
||||
onMount(() => {
|
||||
createClipboardManager(subscriptions, editor);
|
||||
createHyperlinkManager(subscriptions);
|
||||
createLocalizationManager(subscriptions, editor);
|
||||
createPanicManager(subscriptions);
|
||||
createPersistenceManager(subscriptions, editor, stores.portfolio);
|
||||
createInputManager(subscriptions, editor, stores.dialog, stores.portfolio, stores.document);
|
||||
|
||||
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready.
|
||||
// The backend handles idempotency, so this is safe to call again during HMR re-mounts.
|
||||
editor.initAfterFrontendReady();
|
||||
|
||||
// Re-send all UI layouts from Rust so the frontend has them after an HMR re-mount
|
||||
editor.resendAllLayouts();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Stores
|
||||
destroyDialogStore();
|
||||
destroyTooltipStore();
|
||||
destroyDocumentStore();
|
||||
destroyFullscreenStore();
|
||||
destroyNodeGraphStore();
|
||||
destroyPortfolioStore();
|
||||
destroyAppWindowStore();
|
||||
destroyColorPickerStore();
|
||||
|
||||
// Managers
|
||||
destroyClipboardManager();
|
||||
destroyHyperlinkManager();
|
||||
destroyLocalizationManager();
|
||||
destroyPanicManager();
|
||||
destroyPersistenceManager();
|
||||
destroyInputManager();
|
||||
});
|
||||
</script>
|
||||
|
||||
<MainWindow />
|
||||
|
||||
<style lang="scss">
|
||||
// Disable the spinning loading indicator
|
||||
body::before,
|
||||
body::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
// Replace usage of `-rgb` variants with CSS color() function to calculate alpha when browsers support it
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color() and https://caniuse.com/css-color-function
|
||||
// Specifically, support for the relative syntax is needed: `color(from var(--color-0-black) srgb r g b / 0.5)` to convert black to 50% alpha
|
||||
--color-0-black: #000;
|
||||
--color-0-black-rgb: 0, 0, 0;
|
||||
--color-1-nearblack: #111;
|
||||
--color-1-nearblack-rgb: 17, 17, 17;
|
||||
--color-2-mildblack: #222;
|
||||
--color-2-mildblack-rgb: 34, 34, 34;
|
||||
--color-3-darkgray: #333;
|
||||
--color-3-darkgray-rgb: 51, 51, 51;
|
||||
--color-4-dimgray: #444;
|
||||
--color-4-dimgray-rgb: 68, 68, 68;
|
||||
--color-5-dullgray: #555;
|
||||
--color-5-dullgray-rgb: 85, 85, 85;
|
||||
--color-6-lowergray: #666;
|
||||
--color-6-lowergray-rgb: 102, 102, 102;
|
||||
--color-7-middlegray: #777;
|
||||
--color-7-middlegray-rgb: 109, 109, 109;
|
||||
--color-8-uppergray: #888;
|
||||
--color-8-uppergray-rgb: 136, 136, 136;
|
||||
--color-9-palegray: #999;
|
||||
--color-9-palegray-rgb: 153, 153, 153;
|
||||
--color-a-softgray: #aaa;
|
||||
--color-a-softgray-rgb: 170, 170, 170;
|
||||
--color-b-lightgray: #bbb;
|
||||
--color-b-lightgray-rgb: 187, 187, 187;
|
||||
--color-c-brightgray: #ccc;
|
||||
--color-c-brightgray-rgb: 204, 204, 204;
|
||||
--color-d-mildwhite: #ddd;
|
||||
--color-d-mildwhite-rgb: 221, 221, 221;
|
||||
--color-e-nearwhite: #eee;
|
||||
--color-e-nearwhite-rgb: 238, 238, 238;
|
||||
--color-f-white: #fff;
|
||||
--color-f-white-rgb: 255, 255, 255;
|
||||
--color-error-red: #d6536e;
|
||||
--color-error-red-rgb: 214, 83, 110;
|
||||
--color-warning-yellow: #d5aa43;
|
||||
--color-warning-yellow-rgb: 213, 170, 67;
|
||||
|
||||
--color-data-general: #cfcfcf;
|
||||
--color-data-general-dim: #8a8a8a;
|
||||
--color-data-number: #c9a699;
|
||||
--color-data-number-dim: #886b60;
|
||||
--color-data-artboard: #fbf9eb;
|
||||
--color-data-artboard-dim: #b9b9a9;
|
||||
--color-data-graphic: #68c587;
|
||||
--color-data-graphic-dim: #37754c;
|
||||
--color-data-raster: #e4bb72;
|
||||
--color-data-raster-dim: #9a7b43;
|
||||
--color-data-vector: #65bbe5;
|
||||
--color-data-vector-dim: #417892;
|
||||
--color-data-color: #ce6ea7;
|
||||
--color-data-color-dim: #924071;
|
||||
--color-data-gradient: #af81eb;
|
||||
--color-data-gradient-dim: #6c489b;
|
||||
--color-data-typography: #eea7a7;
|
||||
--color-data-typography-dim: #955252;
|
||||
--color-data-invalid: #d6536e; // Same as --color-error-red
|
||||
--color-data-invalid-dim: #a7324a;
|
||||
|
||||
--color-overlay-blue: #00a8ff;
|
||||
|
||||
--color-none: white;
|
||||
--color-none-repeat: no-repeat;
|
||||
--color-none-position: center center;
|
||||
// 24px tall, 48px wide
|
||||
--color-none-size-24px: 60px 24px;
|
||||
// Red diagonal slash (24px tall)
|
||||
--color-none-image-24px: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 24"><line stroke="red" stroke-width="4px" x1="0" y1="27" x2="60" y2="-3" /></svg>\
|
||||
');
|
||||
// 32px tall, 64px wide
|
||||
--color-none-size-32px: 80px 32px;
|
||||
// Red diagonal slash (32px tall)
|
||||
--color-none-image-32px: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 32"><line stroke="red" stroke-width="4px" x1="0" y1="36" x2="80" y2="-4" /></svg>\
|
||||
');
|
||||
|
||||
--color-transparent-checkered-background:
|
||||
linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
|
||||
linear-gradient(#ffffff, #ffffff);
|
||||
--color-transparent-checkered-background-size: 16px 16px, 16px 16px, 16px 16px;
|
||||
--color-transparent-checkered-background-position: 0 0, 8px 8px, 8px 8px;
|
||||
--color-transparent-checkered-background-position-plus-one: 1px 1px, 9px 9px, 9px 9px;
|
||||
--color-transparent-checkered-background-size-mini: 8px 8px, 8px 8px, 8px 8px;
|
||||
--color-transparent-checkered-background-position-mini: 0 0, 4px 4px, 4px 4px;
|
||||
--color-transparent-checkered-background-repeat: repeat, repeat, repeat;
|
||||
|
||||
--inheritance-stripes-background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0px,
|
||||
transparent calc((3px * sqrt(2) / 2) - 0.5px),
|
||||
var(--color-5-dullgray) calc((3px * sqrt(2) / 2) - 0.5px),
|
||||
var(--color-5-dullgray) calc((3px * sqrt(2) / 2) + 0.5px),
|
||||
transparent calc((3px * sqrt(2) / 2) + 0.5px),
|
||||
transparent calc(6px * sqrt(2) / 2)
|
||||
);
|
||||
--inheritance-dots-background-4-dimgray: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4" width="4px" height="4px" fill="%23444"><rect width="1" height="1" /><rect x="2" y="2" width="1" height="1" /></svg>\
|
||||
');
|
||||
--inheritance-dots-background-6-lowergray: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4" width="4px" height="4px" fill="%23666"><rect width="1" height="1" /><rect x="2" y="2" width="1" height="1" /></svg>\
|
||||
');
|
||||
|
||||
// Array of 2x3 dots (fill: --color-e-nearwhite)
|
||||
--icon-drag-grip: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 24" fill="%23eee">\
|
||||
<circle cx="0.5" cy="1.5" r="0.5" /><circle cx="3.5" cy="1.5" r="0.5" />\
|
||||
<circle cx="0.5" cy="4.5" r="0.5" /><circle cx="3.5" cy="4.5" r="0.5" />\
|
||||
<circle cx="0.5" cy="7.5" r="0.5" /><circle cx="3.5" cy="7.5" r="0.5" />\
|
||||
</svg>\
|
||||
');
|
||||
// Array of 2x3 dots (fill: --color-f-white)
|
||||
--icon-drag-grip-hover: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 24" fill="%23fff">\
|
||||
<circle cx="0.5" cy="1.5" r="0.5" /><circle cx="3.5" cy="1.5" r="0.5" />\
|
||||
<circle cx="0.5" cy="4.5" r="0.5" /><circle cx="3.5" cy="4.5" r="0.5" />\
|
||||
<circle cx="0.5" cy="7.5" r="0.5" /><circle cx="3.5" cy="7.5" r="0.5" />\
|
||||
</svg>\
|
||||
');
|
||||
// Array of 2x3 dots (fill: --color-8-uppergray)
|
||||
--icon-drag-grip-disabled: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 24" fill="%23888">\
|
||||
<circle cx="0.5" cy="1.5" r="0.5" /><circle cx="3.5" cy="1.5" r="0.5" />\
|
||||
<circle cx="0.5" cy="4.5" r="0.5" /><circle cx="3.5" cy="4.5" r="0.5" />\
|
||||
<circle cx="0.5" cy="7.5" r="0.5" /><circle cx="3.5" cy="7.5" r="0.5" />\
|
||||
</svg>\
|
||||
');
|
||||
|
||||
// Arrow triangle (fill: --color-e-nearwhite)
|
||||
--icon-expand-collapse-arrow: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23eee" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>\
|
||||
');
|
||||
// Arrow triangle (fill: --color-f-white)
|
||||
--icon-expand-collapse-arrow-hover: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23fff" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>\
|
||||
');
|
||||
// Arrow triangle (fill: --color-8-uppergray)
|
||||
--icon-expand-collapse-arrow-disabled: url('data:image/svg+xml;utf8,\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23888" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>\
|
||||
');
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--color-2-mildblack);
|
||||
overscroll-behavior: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body.cursor-hidden * {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
// Needed for the viewport hole punch on desktop
|
||||
html:has(body > .viewport-hole-punch),
|
||||
body:has(> .viewport-hole-punch) {
|
||||
background: none;
|
||||
}
|
||||
|
||||
// The default value of `auto` from the CSS spec is a footgun with flexbox layouts:
|
||||
// https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size
|
||||
* {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
font-family: "Source Sans Pro", Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
tab-size: 4;
|
||||
color: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
body,
|
||||
textarea,
|
||||
input {
|
||||
&::selection {
|
||||
background-color: var(--color-4-dimgray);
|
||||
|
||||
// Target only Safari
|
||||
@supports (background: -webkit-named-image(i)) {
|
||||
& {
|
||||
// Setting an alpha value opts out of Safari's "fancy" (but not visible on dark backgrounds) selection highlight rendering
|
||||
// https://stackoverflow.com/a/71753552/775283
|
||||
background-color: rgba(var(--color-4-dimgray-rgb), calc(254 / 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg,
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layout-row,
|
||||
.layout-col {
|
||||
.scrollable-x,
|
||||
.scrollable-y {
|
||||
overflow: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-4-dimgray) transparent;
|
||||
}
|
||||
|
||||
.scrollable-x.scrollable-y {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scrollable-x:not(.scrollable-y) {
|
||||
scrollbar-width: none;
|
||||
overflow: auto hidden;
|
||||
}
|
||||
|
||||
.scrollable-y:not(.scrollable-x) {
|
||||
overflow: hidden auto;
|
||||
}
|
||||
}
|
||||
|
||||
// List of all elements that should show an outline when focused by tabbing or by clicking the element
|
||||
.dropdown-input .dropdown-box,
|
||||
.font-input .dropdown-box {
|
||||
&:focus {
|
||||
outline: 1px dashed var(--color-e-nearwhite);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
// List of all elements that should show an outline when focused by tabbing, but not by clicking the element
|
||||
.icon-button,
|
||||
.text-button,
|
||||
.popover-button,
|
||||
.color-input > button,
|
||||
.color-picker .preset-color,
|
||||
.working-colors-input .swatch > button,
|
||||
.radio-input button,
|
||||
.menu-list,
|
||||
.menu-list-button .entry,
|
||||
.layer-tree .expand-arrow,
|
||||
.widget-section .header {
|
||||
&:focus-visible {
|
||||
outline: 1px dashed var(--color-e-nearwhite);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
// Variant: dark outline over light colors
|
||||
&.preset-color.white,
|
||||
&.text-button.emphasized {
|
||||
&:focus-visible {
|
||||
outline: 1px dashed var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Checkbox needs to apply the focus outline to its sibling label
|
||||
.optional-input input:focus-visible + label,
|
||||
.checkbox-input input:focus-visible + label {
|
||||
outline: 1px dashed var(--color-e-nearwhite);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
// Variant: dark outline over light colors (when the checkbox is checked)
|
||||
:not(.optional-input) > .checkbox-input input:focus-visible + label.checked {
|
||||
outline: 1px dashed var(--color-2-mildblack);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-stretch: normal;
|
||||
src: url("/node_modules/source-sans-pro/WOFF2/TTF/SourceSansPro-Regular.ttf.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-stretch: normal;
|
||||
src: url("/node_modules/source-sans-pro/WOFF2/TTF/SourceSansPro-It.ttf.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-stretch: normal;
|
||||
src: url("/node_modules/source-sans-pro/WOFF2/TTF/SourceSansPro-Bold.ttf.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-stretch: normal;
|
||||
src: url("/node_modules/source-sans-pro/WOFF2/TTF/SourceSansPro-BoldIt.ttf.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Source Code Pro";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-stretch: normal;
|
||||
src: url("/node_modules/source-code-pro/WOFF2/TTF/SourceCodePro-Regular.ttf.woff2") format("woff2");
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
# Overview of `/frontend/src/components/`
|
||||
|
||||
Each component represents a (usually reusable) part of the Graphite editor GUI.
|
||||
|
||||
## Floating Menus: `floating-menus/`
|
||||
|
||||
The temporary UI areas with dark backgrounds which hover over the top of the editor window content. Examples include menu lists, popovers, and dialogs.
|
||||
|
||||
## Layout: `layout/`
|
||||
|
||||
Useful containers that control the flow of content held within.
|
||||
|
||||
## Panels: `panels/`
|
||||
|
||||
The dockable tabbed regions like the Document, Properties, Layers, Data, and Welcome panels.
|
||||
|
||||
## Views: `views/`
|
||||
|
||||
Content views rendered within panels, such as the node graph.
|
||||
|
||||
## Widgets: `widgets/`
|
||||
|
||||
The interactive input items used to display information and provide user control.
|
||||
|
||||
## Window: `window/`
|
||||
|
||||
The building blocks for the Title Bar, Workspace, and Status Bar within an editor application window.
|
||||
|
||||
# Svelte tips and tricks
|
||||
|
||||
This section contains a growing list of quick reference information for helpful Svelte solutions and best practices. Feel free to add to this to help contributors learn things, or yourself remember tricks you'll likely forget in a few months.
|
||||
|
||||
## Bi-directional props
|
||||
|
||||
The component declares this:
|
||||
|
||||
```ts
|
||||
// The dispatcher that sends the changed value as a custom event to the parent
|
||||
const dispatch = createEventDispatcher<{ theBidirectionalProperty: number }>();
|
||||
|
||||
// The prop
|
||||
export let theBidirectionalProperty: number;
|
||||
|
||||
// Called only when `theBidirectionalProperty` is changed from outside this component via its props
|
||||
$: console.log(theBidirectionalProperty);
|
||||
|
||||
// Example of a method that would update the value
|
||||
function doSomething() {
|
||||
dispatch("theBidirectionalProperty", SOME_NEW_VALUE);
|
||||
},
|
||||
```
|
||||
|
||||
Users of the component do this for `theCorrespondingDataEntry` to be a two-way binding:
|
||||
|
||||
```ts
|
||||
let theCorrespondingDataEntry = 42;
|
||||
```
|
||||
|
||||
```svelte
|
||||
<DropdownInput
|
||||
theBidirectionalProperty={theCorrespondingDataEntry}
|
||||
on:theBidirectionalProperty={({ detail }) => { theCorrespondingDataEntry = detail; }}
|
||||
/>
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onDestroy } from "svelte";
|
||||
import FloatingMenu from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { ColorPickerCallbacks, ColorPickerStore } from "/src/stores/color-picker";
|
||||
import type { EditorWrapper, FillChoiceUI, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ colorOrGradient: FillChoiceUI; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>();
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const colorPickerStore = getContext<ColorPickerStore>("colorPicker");
|
||||
|
||||
export let colorOrGradient: FillChoiceUI;
|
||||
export let allowNone = false;
|
||||
// export let allowTransparency = false; // TODO: Implement
|
||||
export let disabled = false;
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
// TODO: See if this should be made to follow the pattern of DropdownInput.svelte so this could be removed
|
||||
export let open: boolean;
|
||||
|
||||
// Suppress the FloatingMenu's stray-close while the user is dragging one of the visual H/S/V/A pickers.
|
||||
$: strayCloses = !$colorPickerStore.isDragging;
|
||||
let self: FloatingMenu | undefined;
|
||||
|
||||
// Open/close lifecycle: when `open` flips, register/clear the global callbacks (so events route to *this* instance)
|
||||
// and tell the Rust handler to (re)initialize its state from the current `colorOrGradient`.
|
||||
let lastOpen = false;
|
||||
// Identity used by `clearCallbacks` to skip stale clears when another picker has already taken over the store's callbacks
|
||||
let installedCallbacks: ColorPickerCallbacks | undefined;
|
||||
$: handleOpenChange(open);
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen && !lastOpen) {
|
||||
installedCallbacks = {
|
||||
onColorChanged: (value) => dispatch("colorOrGradient", value),
|
||||
onStartTransaction: () => dispatch("startHistoryTransaction"),
|
||||
onCommitTransaction: () => dispatch("commitHistoryTransaction"),
|
||||
};
|
||||
colorPickerStore.setCallbacks(installedCallbacks);
|
||||
editor.openColorPicker(colorOrGradient, allowNone, disabled);
|
||||
// Auto-select the hex color code text input. Deferred so the layout has time to render after the picker opens.
|
||||
setTimeout(() => {
|
||||
const hexInput = self?.div()?.querySelector(".text-input input");
|
||||
if (hexInput instanceof HTMLInputElement) hexInput.select();
|
||||
}, 0);
|
||||
} else if (!isOpen && lastOpen) {
|
||||
if (installedCallbacks) colorPickerStore.clearCallbacks(installedCallbacks);
|
||||
installedCallbacks = undefined;
|
||||
editor.closeColorPicker();
|
||||
}
|
||||
lastOpen = isOpen;
|
||||
}
|
||||
|
||||
export function div(): HTMLDivElement | undefined {
|
||||
return self?.div();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (!lastOpen) return;
|
||||
if (installedCallbacks) colorPickerStore.clearCallbacks(installedCallbacks);
|
||||
installedCallbacks = undefined;
|
||||
editor.closeColorPicker();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FloatingMenu class="color-picker" classes={{ disabled }} {open} on:open {strayCloses} escapeCloses={strayCloses} {direction} type="Popover" bind:this={self}>
|
||||
<LayoutRow>
|
||||
<LayoutCol class="pickers-and-gradient">
|
||||
<WidgetLayout layout={$colorPickerStore.pickersAndGradient} layoutTarget="ColorPickerPickersAndGradient" />
|
||||
</LayoutCol>
|
||||
<LayoutCol class="details">
|
||||
<WidgetLayout layout={$colorPickerStore.details} layoutTarget="ColorPickerDetails" />
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss">
|
||||
.color-picker {
|
||||
--widget-height: 24px;
|
||||
|
||||
.pickers-and-gradient .widget-span {
|
||||
--row-height: 24px;
|
||||
|
||||
&:has(.spectrum-input) {
|
||||
margin-top: 16px;
|
||||
|
||||
.number-input {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 8px;
|
||||
min-width: 0;
|
||||
width: calc(24px + 8px + 24px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-left: 16px;
|
||||
width: 200px;
|
||||
gap: 8px;
|
||||
|
||||
> .widget-span {
|
||||
--row-height: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
> .text-label {
|
||||
// TODO: Use a table or grid layout for this width to match the widest label. Hard-coding it won't work when we add translation/localization.
|
||||
flex: 0 0 34px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
import FloatingMenu from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import TextButton from "/src/components/widgets/buttons/TextButton.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { DialogStore } from "/src/stores/dialog";
|
||||
import { crashReportUrl } from "/src/utility-functions/crash-report";
|
||||
import { wipeDocuments } from "/src/utility-functions/persistence";
|
||||
|
||||
const dialog = getContext<DialogStore>("dialog");
|
||||
|
||||
let self: FloatingMenu | undefined;
|
||||
|
||||
onMount(() => {
|
||||
// Focus the button which is marked as emphasized, or otherwise the first button, in the popup
|
||||
const button = self?.div?.()?.querySelector("[data-emphasized]") || self?.div?.()?.querySelector("[data-text-button]");
|
||||
const emphasizedOrFirstButton = button instanceof HTMLButtonElement ? button : undefined;
|
||||
emphasizedOrFirstButton?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- TODO: Use https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog for improved accessibility -->
|
||||
<FloatingMenu open={true} class="dialog" type="Dialog" direction="Center" bind:this={self} data-dialog>
|
||||
<LayoutRow class="header-area">
|
||||
{#if $dialog.icon}
|
||||
<IconLabel icon={$dialog.icon} />
|
||||
{/if}
|
||||
<TextLabel>{$dialog.title}</TextLabel>
|
||||
</LayoutRow>
|
||||
<LayoutRow class={`content ${$dialog.title === "Demo Artwork" ? "center" : "" /* TODO: Replace this with a less hacky approach that's compatible with localization/translation */}`}>
|
||||
<LayoutCol class="column-1">
|
||||
{#if $dialog.column1.length > 0}
|
||||
<WidgetLayout layout={$dialog.column1} layoutTarget="DialogColumn1" class="details" />
|
||||
{/if}
|
||||
{#if $dialog.panicDetails}
|
||||
<div class="widget-layout details">
|
||||
<div class="widget-span row"><TextLabel bold={true}>The editor crashed — sorry about that</TextLabel></div>
|
||||
<div class="widget-span row"><TextLabel>Please report this by filing an issue on GitHub:</TextLabel></div>
|
||||
<div class="widget-span row"><TextButton label="Report Bug" icon="Warning" flush={true} action={() => window.open(crashReportUrl($dialog.panicDetails), "_blank")} /></div>
|
||||
<div class="widget-span row"><TextLabel multiline={true}>Reload the editor to continue. If this occurs<br />immediately on repeated reloads, clear storage:</TextLabel></div>
|
||||
<div class="widget-span row">
|
||||
<TextButton
|
||||
label="Clear Saved Documents"
|
||||
icon="Trash"
|
||||
flush={true}
|
||||
action={async () => {
|
||||
await wipeDocuments();
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
{#if $dialog.column2.length > 0}
|
||||
<LayoutCol class="column-2">
|
||||
<WidgetLayout layout={$dialog.column2} layoutTarget="DialogColumn2" class="details" />
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
<LayoutRow class="footer-area">
|
||||
{#if $dialog.buttons.length > 0}
|
||||
<WidgetLayout layout={$dialog.buttons} layoutTarget="DialogButtons" class="details" />
|
||||
{/if}
|
||||
{#if $dialog.panicDetails}
|
||||
<TextButton label="Copy Error Log" action={() => navigator.clipboard.writeText($dialog.panicDetails)} />
|
||||
<TextButton label="Reload" emphasized={true} action={() => window.location.reload()} />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss">
|
||||
.dialog {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> .floating-menu-container > .floating-menu-content {
|
||||
pointer-events: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-area,
|
||||
.footer-area {
|
||||
background: var(--color-1-nearblack);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.header-area,
|
||||
.footer-area,
|
||||
.content {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.header-area {
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
||||
.icon-label {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
+ .text-label {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: -4px 0;
|
||||
padding-right: calc(24px + 1px * var(--even-integer-subpixel-expansion-x));
|
||||
padding-bottom: calc(16px + 1px * var(--even-integer-subpixel-expansion-y));
|
||||
|
||||
&.center .row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.column-1 + .column-2 {
|
||||
margin-left: 48px;
|
||||
|
||||
.text-button {
|
||||
justify-content: left;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-input button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.text-label.multiline {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
// Used by the "Third-Party Software License Notices" dialog
|
||||
.details:has(.text-label.multiline.monospace) {
|
||||
max-height: 60vh;
|
||||
max-width: 80vw;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// Used by the "Open Demo Artwork" dialog
|
||||
.image-label {
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-area {
|
||||
border-radius: 0 0 4px 4px;
|
||||
justify-content: right;
|
||||
|
||||
.text-button {
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" context="module">
|
||||
// Should be equal to the width and height of the zoom preview canvas in the CSS
|
||||
const ZOOM_WINDOW_DIMENSIONS_EXPANDED = 110;
|
||||
// Should be equal to the width and height of the `.pixel-outline` div in the CSS, and should be evenly divisible into the number above
|
||||
const UPSCALE_FACTOR = 10;
|
||||
|
||||
export const ZOOM_WINDOW_DIMENSIONS = ZOOM_WINDOW_DIMENSIONS_EXPANDED / UPSCALE_FACTOR;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import FloatingMenu from "/src/components/layout/FloatingMenu.svelte";
|
||||
|
||||
let temporaryCanvas: HTMLCanvasElement | undefined;
|
||||
let zoomPreviewCanvas: HTMLCanvasElement | undefined;
|
||||
|
||||
export let imageData: ImageData | undefined = undefined;
|
||||
export let colorChoice: string;
|
||||
export let primaryColor: string;
|
||||
export let secondaryColor: string;
|
||||
export let x: number;
|
||||
export let y: number;
|
||||
|
||||
$: displayImageDataPreview(imageData);
|
||||
|
||||
function displayImageDataPreview(imageData: ImageData | undefined) {
|
||||
if (!zoomPreviewCanvas) return;
|
||||
const context = zoomPreviewCanvas.getContext("2d");
|
||||
|
||||
if (!temporaryCanvas) {
|
||||
temporaryCanvas = document.createElement("canvas");
|
||||
temporaryCanvas.width = ZOOM_WINDOW_DIMENSIONS;
|
||||
temporaryCanvas.height = ZOOM_WINDOW_DIMENSIONS;
|
||||
}
|
||||
const temporaryContext = temporaryCanvas.getContext("2d");
|
||||
|
||||
if (!imageData || !context || !temporaryContext) return;
|
||||
|
||||
temporaryContext.putImageData(imageData, 0, 0, 0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
||||
|
||||
context.fillStyle = "black";
|
||||
context.fillRect(0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
||||
|
||||
context.drawImage(temporaryCanvas, 0, 0);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
displayImageDataPreview(imageData);
|
||||
});
|
||||
</script>
|
||||
|
||||
<FloatingMenu
|
||||
open={true}
|
||||
class="eyedropper-preview"
|
||||
type="Cursor"
|
||||
styles={{ "--ring-color-primary": primaryColor, "--ring-color-secondary": secondaryColor, "--ring-color-choice": colorChoice, left: x + "px", top: y + "px" }}
|
||||
>
|
||||
<div class="ring">
|
||||
<div class="canvas-container">
|
||||
<canvas width={ZOOM_WINDOW_DIMENSIONS} height={ZOOM_WINDOW_DIMENSIONS} bind:this={zoomPreviewCanvas}></canvas>
|
||||
<div class="pixel-outline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss">
|
||||
.eyedropper-preview {
|
||||
pointer-events: none;
|
||||
|
||||
.floating-menu-content.floating-menu-content {
|
||||
border: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.ring {
|
||||
transform: translate(0, -50%) rotate(45deg);
|
||||
position: relative;
|
||||
background: var(--ring-color-choice);
|
||||
padding: 16px;
|
||||
border: 8px solid;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--ring-color-primary);
|
||||
border-left-color: var(--ring-color-primary);
|
||||
border-bottom-color: var(--ring-color-secondary);
|
||||
border-right-color: var(--ring-color-secondary);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.5),
|
||||
0 0 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
transform: rotate(-45deg);
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 50%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.5),
|
||||
inset 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.pixel-outline {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
--outline-width: 2;
|
||||
margin-top: calc(-1px * (var(--outline-width) / 2));
|
||||
width: calc(10px - (var(--outline-width) * 1px));
|
||||
height: calc(10px - var(--outline-width) * 1px);
|
||||
border: calc(var(--outline-width) * 1px) solid var(--color-0-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,677 @@
|
||||
<svelte:options accessors={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, tick, onDestroy, onMount } from "svelte";
|
||||
import MenuList from "/src/components/floating-menus/MenuList.svelte";
|
||||
import FloatingMenu from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import TextInput from "/src/components/widgets/inputs/TextInput.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { MenuListEntry, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let self: FloatingMenu | undefined;
|
||||
let scroller: LayoutCol | undefined;
|
||||
let searchTextInput: TextInput | undefined;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
open: boolean;
|
||||
activeEntry: MenuListEntry;
|
||||
selectedEntryValuePath: string[];
|
||||
hoverInEntry: MenuListEntry;
|
||||
hoverOutEntry: undefined;
|
||||
naturalWidth: number;
|
||||
}>();
|
||||
|
||||
export let parentsValuePath: string[] = [];
|
||||
export let entries: MenuListEntry[][];
|
||||
export let entriesHash: bigint;
|
||||
export let activeEntry: MenuListEntry | undefined = undefined;
|
||||
export let open: boolean;
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
export let minWidth = 0;
|
||||
export let drawIcon = false;
|
||||
export let interactive = false;
|
||||
export let scrollableY = false;
|
||||
export let virtualScrolling = false;
|
||||
|
||||
// Keep the child references outside of the entries array so as to avoid infinite recursion.
|
||||
let childReferences: MenuList[][] = [];
|
||||
let openChildValue: string | undefined = undefined;
|
||||
let search = "";
|
||||
let reactiveEntries = entries;
|
||||
let highlighted: MenuListEntry | undefined = activeEntry;
|
||||
let virtualScrollingEntriesStart = 0;
|
||||
let keydownListenerAdded = false;
|
||||
let destroyed = false;
|
||||
let maxMenuWidth = 0;
|
||||
let resizeObserver: ResizeObserver | undefined = undefined;
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- `loadedFonts` reactivity is driven by `loadedFontsGeneration`, not the Set itself
|
||||
let loadedFonts = new Set<string>();
|
||||
let loadedFontsGeneration = 0;
|
||||
|
||||
// `watchOpen` is called only when `open` is changed from outside this component
|
||||
$: watchOpen(open);
|
||||
$: watchEntries(entries);
|
||||
$: watchEntriesHash(entriesHash);
|
||||
$: watchRemeasureWidth(filteredEntries, drawIcon);
|
||||
$: watchHighlightedWithSearch(filteredEntries, open);
|
||||
|
||||
$: virtualScrollingEntryHeight = virtualScrolling ? 20 : 0;
|
||||
$: filteredEntries = reactiveEntries.map((section) => section.filter((entry) => inSearch(search, entry)));
|
||||
$: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0;
|
||||
// Virtual scrolling calculations
|
||||
$: virtualScrollingTotalHeight = filteredEntries.length === 0 ? 0 : filteredEntries[0].length * virtualScrollingEntryHeight;
|
||||
$: virtualScrollingStartIndex = filteredEntries.length === 0 ? 0 : Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
|
||||
$: virtualScrollingEndIndex = filteredEntries.length === 0 ? 0 : Math.min(filteredEntries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
|
||||
|
||||
// TODO: Move keyboard input handling entirely to the unified system in `input.ts`.
|
||||
// TODO: The current approach is hacky and blocks the allowances for shortcuts like the key to open the browser's dev tools.
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
if (!destroyed && open && !inNestedMenuList() && !keydownListenerAdded) {
|
||||
addEventListener("keydown", keydown);
|
||||
keydownListenerAdded = true;
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
removeEventListener("keydown", keydown);
|
||||
resizeObserver?.disconnect();
|
||||
// Set the destroyed status in the closure kept by the awaited `tick()` in `onMount` in case that delayed run occurs after the component is destroyed
|
||||
destroyed = true;
|
||||
});
|
||||
|
||||
function inNestedMenuList(): boolean {
|
||||
const div = self?.div();
|
||||
if (!(div instanceof HTMLDivElement)) return false;
|
||||
return Boolean(div.closest("[data-floating-menu-content]"));
|
||||
}
|
||||
|
||||
// Required to keep the highlighted item centered and to find a new highlighted item if necessary
|
||||
async function watchHighlightedWithSearch(filteredEntries: MenuListEntry[][], open: boolean) {
|
||||
if (highlighted && open) {
|
||||
// Allows the scrollable area to expand if necessary
|
||||
await tick();
|
||||
|
||||
const flattened = filteredEntries.flat();
|
||||
const highlightedFound = flattened.map((entry) => entry.value).includes(highlighted.value);
|
||||
const newHighlighted = highlightedFound ? highlighted : flattened[0];
|
||||
setHighlighted(newHighlighted);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect when the user types, which creates a search box
|
||||
async function startSearch(e: KeyboardEvent) {
|
||||
// Only accept single-character symbol inputs other than space
|
||||
if (e.key.length !== 1 || e.key === " ") return;
|
||||
|
||||
// Stop shortcuts being activated
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Forward the input's first character to the search box, which after that point the user will continue typing into directly
|
||||
search = e.key;
|
||||
|
||||
// Must wait until the DOM elements have been created (after the if condition becomes true) before the search box exists
|
||||
await tick();
|
||||
|
||||
// Get the search box element
|
||||
const searchElement = searchTextInput?.element();
|
||||
if (!searchTextInput || !searchElement) return;
|
||||
|
||||
// Focus the search box and move the cursor to the end
|
||||
searchTextInput.focus();
|
||||
searchElement.setSelectionRange(search.length, search.length);
|
||||
|
||||
// Continue listening for keyboard navigation even when the search box is focused
|
||||
// searchElement.onkeydown = (e) => {
|
||||
// if (["Enter", "Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||
// keydown(e, false);
|
||||
// }
|
||||
// };
|
||||
}
|
||||
|
||||
function inSearch(search: string, entry: MenuListEntry): boolean {
|
||||
return !search || entry.label.toLowerCase().includes(search.toLowerCase());
|
||||
}
|
||||
|
||||
function watchOpen(open: boolean) {
|
||||
if (open && !inNestedMenuList() && !keydownListenerAdded) {
|
||||
addEventListener("keydown", keydown);
|
||||
keydownListenerAdded = true;
|
||||
} else if (!open && !inNestedMenuList() && keydownListenerAdded) {
|
||||
removeEventListener("keydown", keydown);
|
||||
keydownListenerAdded = false;
|
||||
}
|
||||
|
||||
// For virtual scrolling menus, observe width changes so the menu only grows and never shrinks while open
|
||||
if (open && virtualScrolling) {
|
||||
startMenuWidthObserver();
|
||||
} else if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = undefined;
|
||||
maxMenuWidth = 0;
|
||||
}
|
||||
|
||||
highlighted = activeEntry;
|
||||
dispatch("open", open);
|
||||
|
||||
search = "";
|
||||
}
|
||||
|
||||
function watchEntries(entries: MenuListEntry[][]) {
|
||||
entries.forEach((_, index) => {
|
||||
if (!childReferences[index]) childReferences[index] = [];
|
||||
});
|
||||
}
|
||||
|
||||
function watchEntriesHash(_: bigint) {
|
||||
reactiveEntries = entries;
|
||||
}
|
||||
|
||||
function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
|
||||
// Skip re-measurement for virtual scrolling menus since ResizeObserver handles their width
|
||||
if (virtualScrolling) return;
|
||||
|
||||
self?.measureAndEmitNaturalWidth();
|
||||
}
|
||||
|
||||
async function startMenuWidthObserver() {
|
||||
await tick();
|
||||
// Guard against the menu having closed during the tick
|
||||
if (!open) return;
|
||||
|
||||
const floatingMenuContentDiv = self?.div()?.querySelector("[data-floating-menu-content]");
|
||||
if (!(floatingMenuContentDiv instanceof HTMLElement)) return;
|
||||
|
||||
maxMenuWidth = 0;
|
||||
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const width = floatingMenuContentDiv.scrollWidth;
|
||||
if (width > maxMenuWidth) {
|
||||
maxMenuWidth = width;
|
||||
floatingMenuContentDiv.style.minWidth = `${maxMenuWidth}px`;
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(floatingMenuContentDiv);
|
||||
}
|
||||
|
||||
function onScroll(e: Event) {
|
||||
if (!virtualScrollingEntryHeight) return;
|
||||
virtualScrollingEntriesStart = e.target instanceof HTMLElement ? e.target.scrollTop : 0;
|
||||
}
|
||||
|
||||
function getChildReference(menuListEntry: MenuListEntry): MenuList | undefined {
|
||||
const index = filteredEntries.flat().findIndex((entry) => entry.value === menuListEntry.value);
|
||||
|
||||
if (index !== -1) {
|
||||
return childReferences.flat().filter((x) => x)[index];
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("MenuListEntry not found in filteredEntries:", menuListEntry);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function onEntryClick(menuListEntry: MenuListEntry) {
|
||||
// Close the containing menu
|
||||
let childReference = getChildReference(menuListEntry);
|
||||
if (childReference) {
|
||||
openChildValue = undefined;
|
||||
reactiveEntries = reactiveEntries;
|
||||
}
|
||||
dispatch("open", false);
|
||||
open = false;
|
||||
reactiveEntries = reactiveEntries;
|
||||
|
||||
// Notify the parent about the clicked entry as the new active entry
|
||||
dispatch("activeEntry", menuListEntry);
|
||||
dispatch("selectedEntryValuePath", [...parentsValuePath, menuListEntry.value]);
|
||||
}
|
||||
|
||||
function onEntryPointerEnter(menuListEntry: MenuListEntry) {
|
||||
if (!menuListEntry.children?.length) {
|
||||
dispatch("hoverInEntry", menuListEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
let childReference = getChildReference(menuListEntry);
|
||||
if (childReference) {
|
||||
openChildValue = menuListEntry.value;
|
||||
reactiveEntries = reactiveEntries;
|
||||
} else {
|
||||
dispatch("open", true);
|
||||
}
|
||||
}
|
||||
|
||||
function onEntryPointerLeave(menuListEntry: MenuListEntry) {
|
||||
if (!menuListEntry.children?.length) {
|
||||
dispatch("hoverOutEntry");
|
||||
return;
|
||||
}
|
||||
|
||||
let childReference = getChildReference(menuListEntry);
|
||||
if (childReference) {
|
||||
openChildValue = undefined;
|
||||
reactiveEntries = reactiveEntries;
|
||||
} else {
|
||||
dispatch("open", false);
|
||||
}
|
||||
}
|
||||
|
||||
function includeSeparator(entries: MenuListEntry[][], section: MenuListEntry[], sectionIndex: number, search: string): boolean {
|
||||
const elementsBeforeCurrentSection = entries
|
||||
.slice(0, sectionIndex)
|
||||
.flat()
|
||||
.filter((entry) => inSearch(search, entry));
|
||||
const entriesInCurrentSection = section.filter((entry) => inSearch(search, entry));
|
||||
|
||||
return elementsBeforeCurrentSection.length > 0 && entriesInCurrentSection.length > 0;
|
||||
}
|
||||
|
||||
function currentEntries(section: MenuListEntry[], virtualScrollingEntryHeight: number, virtualScrollingStartIndex: number, virtualScrollingEndIndex: number, search: string) {
|
||||
if (!virtualScrollingEntryHeight) {
|
||||
return section.filter((entry) => inSearch(search, entry));
|
||||
}
|
||||
return section.filter((entry) => inSearch(search, entry)).slice(virtualScrollingStartIndex, virtualScrollingEndIndex);
|
||||
}
|
||||
|
||||
function openSubmenu(highlightedEntry: MenuListEntry): boolean {
|
||||
let childReference = getChildReference(highlightedEntry);
|
||||
// No submenu to open
|
||||
if (!childReference || !highlightedEntry.children?.length) return false;
|
||||
|
||||
openChildValue = highlightedEntry.value;
|
||||
// The reason we bother taking `highlightdEntry` as an argument is because, when this function is called, it can ensure `highlightedEntry` is not undefined.
|
||||
// But here we still have to set `highlighted` to itself so Svelte knows to reactively update it after we set its `childReference.open` property.
|
||||
highlighted = highlighted;
|
||||
|
||||
// Highlight first item
|
||||
childReference.setHighlighted(highlightedEntry.children[0][0]);
|
||||
|
||||
// Submenu was opened
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Handles keyboard navigation for the menu.
|
||||
// Returns a boolean indicating whether the entire menu stack should be dismissed.
|
||||
export function keydown(e: KeyboardEvent, submenu = false): boolean {
|
||||
const menuOpen = open;
|
||||
const flatEntries = filteredEntries.flat().filter((entry) => !entry.disabled);
|
||||
const openChild = (openChildValue !== undefined && flatEntries.findIndex((entry) => entry.value === openChildValue)) || -1;
|
||||
|
||||
// Allow opening menu with space or enter
|
||||
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
|
||||
open = true;
|
||||
highlighted = activeEntry;
|
||||
|
||||
// Keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
// If a submenu is open, have it handle this instead
|
||||
if (menuOpen && openChild >= 0) {
|
||||
const childMenuListEntry = flatEntries[openChild];
|
||||
const childMenu = getChildReference(childMenuListEntry);
|
||||
|
||||
// Redirect the keyboard navigation to a submenu if one is open
|
||||
const shouldCloseStack = childMenu?.keydown(e, true) || false;
|
||||
|
||||
// Highlight the menu item in the parent list that corresponds with the open submenu
|
||||
if (highlighted && e.key !== "Escape") setHighlighted(childMenuListEntry);
|
||||
|
||||
// Handle the child closing the entire menu stack
|
||||
if (shouldCloseStack) open = false;
|
||||
|
||||
// Keep the menu stack open
|
||||
return shouldCloseStack;
|
||||
}
|
||||
|
||||
// Navigate to the next and previous entries with arrow keys
|
||||
if ((menuOpen || interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
|
||||
if (highlighted) {
|
||||
const index = flatEntries.map((entry) => entry.value).indexOf(highlighted.value);
|
||||
newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
|
||||
|
||||
// Interactive dropdowns should lock at the end whereas other dropdowns should loop
|
||||
if (interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex));
|
||||
else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
|
||||
}
|
||||
|
||||
const newEntry = flatEntries[newIndex];
|
||||
setHighlighted(newEntry);
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
// Close menu with escape key
|
||||
if (menuOpen && e.key === "Escape") {
|
||||
open = false;
|
||||
|
||||
// Reset active to before open
|
||||
setHighlighted(activeEntry);
|
||||
|
||||
// Keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
// Click on a highlighted entry with the enter key
|
||||
if (menuOpen && highlighted && e.key === "Enter") {
|
||||
// Handle clicking on an option if enter is pressed
|
||||
if (!highlighted.children?.length) onEntryClick(highlighted);
|
||||
else openSubmenu(highlighted);
|
||||
|
||||
// Stop the event from triggering a press on a new dialog
|
||||
e.preventDefault();
|
||||
|
||||
// Enter should close the entire menu stack
|
||||
return true;
|
||||
}
|
||||
|
||||
// Open a submenu with the right arrow key, space, or enter
|
||||
if (menuOpen && highlighted && (e.key === "ArrowRight" || e.key === " " || e.key === "Enter")) {
|
||||
// Right arrow opens a submenu
|
||||
const openable = openSubmenu(highlighted);
|
||||
|
||||
// Prevent the right arrow from moving the search text cursor if we are opening a submenu
|
||||
if (openable) e.preventDefault();
|
||||
|
||||
// Keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
// Close a submenu with the left arrow key
|
||||
if (menuOpen && e.key === "ArrowLeft") {
|
||||
// Left arrow closes a submenu
|
||||
if (submenu) {
|
||||
open = false;
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start a search with any other key
|
||||
if (menuOpen && search === "") {
|
||||
startSearch(e);
|
||||
|
||||
// Keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
// If nothing happened, keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setHighlighted(newHighlight: MenuListEntry | undefined) {
|
||||
highlighted = newHighlight;
|
||||
|
||||
// Interactive menus should keep the active entry the same as the highlighted one
|
||||
// if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) {
|
||||
// dispatch("activeEntry", newHighlight);
|
||||
// }
|
||||
|
||||
// Scroll into view
|
||||
let container = scroller?.div?.();
|
||||
if (!container || !highlighted) return;
|
||||
let containerBoundingRect = container.getBoundingClientRect();
|
||||
let highlightedIndex = filteredEntries.flat().findIndex((entry) => entry.value === highlighted?.value);
|
||||
|
||||
let selectedBoundingRect = new DOMRect();
|
||||
if (virtualScrollingEntryHeight) {
|
||||
// Special case for virtual scrolling
|
||||
selectedBoundingRect.y = highlightedIndex * virtualScrollingEntryHeight - container.scrollTop + containerBoundingRect.y;
|
||||
selectedBoundingRect.height = virtualScrollingEntryHeight;
|
||||
} else {
|
||||
let entries = Array.from(container.children).filter((element) => element.classList.contains("row"));
|
||||
let element = entries[highlightedIndex - startIndex];
|
||||
if (!element) return;
|
||||
containerBoundingRect = element.getBoundingClientRect();
|
||||
}
|
||||
|
||||
if (containerBoundingRect.y > selectedBoundingRect.y) {
|
||||
container.scrollBy(0, selectedBoundingRect.y - containerBoundingRect.y);
|
||||
}
|
||||
if (containerBoundingRect.y + containerBoundingRect.height < selectedBoundingRect.y + selectedBoundingRect.height) {
|
||||
container.scrollBy(0, selectedBoundingRect.y - (containerBoundingRect.y + containerBoundingRect.height) + selectedBoundingRect.height);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<FloatingMenu
|
||||
class="menu-list"
|
||||
{open}
|
||||
on:open={({ detail }) => (open = detail)}
|
||||
on:naturalWidth
|
||||
type="Dropdown"
|
||||
windowEdgeMargin={0}
|
||||
escapeCloses={false}
|
||||
{direction}
|
||||
{minWidth}
|
||||
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
|
||||
bind:this={self}
|
||||
>
|
||||
{#if search.length > 0}
|
||||
<TextInput class="search" value={search} on:value={({ detail }) => (search = detail)} bind:this={searchTextInput}></TextInput>
|
||||
{/if}
|
||||
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
|
||||
However when we are using the virtual scrolling then we need the layoutcol to be scrolling so we can bind the events without using `self`. -->
|
||||
<LayoutCol
|
||||
bind:this={scroller}
|
||||
scrollableY={scrollableY && virtualScrollingEntryHeight !== 0}
|
||||
on:scroll={onScroll}
|
||||
styles={{ "min-width": virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }}
|
||||
>
|
||||
{#if virtualScrollingEntryHeight}
|
||||
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
||||
{/if}
|
||||
{#each reactiveEntries as section, sectionIndex (sectionIndex)}
|
||||
{#if includeSeparator(reactiveEntries, section, sectionIndex, search)}
|
||||
<Separator style="Section" direction="Vertical" />
|
||||
{/if}
|
||||
{#each currentEntries(section, virtualScrollingEntryHeight, virtualScrollingStartIndex, virtualScrollingEndIndex, search) as entry, entryIndex (entryIndex + startIndex)}
|
||||
<LayoutRow
|
||||
class="row"
|
||||
classes={{ open: openChildValue === entry.value, active: entry.value === highlighted?.value, disabled: Boolean(entry.disabled) }}
|
||||
styles={{ height: virtualScrollingEntryHeight || "20px" }}
|
||||
tooltipLabel={entry.tooltipLabel}
|
||||
tooltipDescription={entry.tooltipDescription}
|
||||
tooltipShortcut={entry.tooltipShortcut}
|
||||
on:click={() => !entry.disabled && onEntryClick(entry)}
|
||||
on:pointerenter={() => !entry.disabled && onEntryPointerEnter(entry)}
|
||||
on:pointerleave={() => !entry.disabled && onEntryPointerLeave(entry)}
|
||||
>
|
||||
{#if entry.icon && drawIcon}
|
||||
<IconLabel icon={entry.icon} iconSizeOverride={16} class="entry-icon" />
|
||||
{:else if drawIcon}
|
||||
<div class="no-icon"></div>
|
||||
{/if}
|
||||
|
||||
{#if entry.font}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href={entry.font}
|
||||
onload={() => {
|
||||
document.fonts.load(`16px "${entry.value}"`).then(() => {
|
||||
loadedFonts.add(entry.value);
|
||||
loadedFontsGeneration += 1; // Modify the dirty trigger
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<TextLabel
|
||||
class="entry-label"
|
||||
classes={{
|
||||
"font-preview": Boolean(entry.font),
|
||||
"font-loaded": loadedFontsGeneration >= 0 && loadedFonts.has(entry.value),
|
||||
}}
|
||||
styles={entry.font ? { "font-family": `"${entry.value}", "Source Sans Pro"` } : {}}
|
||||
>
|
||||
{entry.label}
|
||||
</TextLabel>
|
||||
|
||||
{#if entry.tooltipShortcut?.shortcut.length}
|
||||
<ShortcutLabel shortcut={entry.tooltipShortcut} />
|
||||
{/if}
|
||||
|
||||
{#if entry.children?.length}
|
||||
<IconLabel class="submenu-arrow" icon="DropdownArrow" />
|
||||
{:else}
|
||||
<div class="no-submenu-arrow"></div>
|
||||
{/if}
|
||||
|
||||
{#if entry.children}
|
||||
<MenuList
|
||||
on:naturalWidth={({ detail }) => {
|
||||
// We do a manual dispatch here instead of just `on:naturalWidth` as a workaround for the <script> tag
|
||||
// at the top of this file displaying a "'render' implicitly has return type 'any' because..." error.
|
||||
// See explanation at <https://github.com/sveltejs/language-tools/issues/452#issuecomment-723148184>.
|
||||
dispatch("naturalWidth", detail);
|
||||
}}
|
||||
on:selectedEntryValuePath={({ detail }) => dispatch("selectedEntryValuePath", detail)}
|
||||
parentsValuePath={[...parentsValuePath, entry.value]}
|
||||
open={openChildValue === entry.value}
|
||||
direction="TopRight"
|
||||
entries={entry.children}
|
||||
entriesHash={entry.childrenHash || 0n}
|
||||
{minWidth}
|
||||
{drawIcon}
|
||||
{scrollableY}
|
||||
bind:this={childReferences[sectionIndex][entryIndex + startIndex]}
|
||||
/>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if virtualScrollingEntryHeight}
|
||||
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss">
|
||||
.menu-list {
|
||||
.search {
|
||||
margin: 4px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.floating-menu-container .floating-menu-content.floating-menu-content {
|
||||
padding: 4px 0;
|
||||
|
||||
.separator {
|
||||
margin: 4px 0;
|
||||
|
||||
div {
|
||||
background: var(--color-3-darkgray);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-spacer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 2px;
|
||||
margin: 0 4px;
|
||||
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.no-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
flex: 1 1 100%;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.font-preview:not(.font-loaded) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.entry-icon,
|
||||
.no-icon {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.submenu-arrow {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
.no-submenu-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
// Extend the submenu to the right by the width of the margin outside the row, since we want the submenu to line up with the edge of the menu
|
||||
&.open {
|
||||
// Offset by the margin distance
|
||||
> .menu-list {
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
// Extend the click target by the margin distance so the user can hover to the right of the row, within the margin area, and still have the submenu open
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-e-nearwhite);
|
||||
color: var(--color-2-mildblack);
|
||||
|
||||
> .icon-label {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--color-8-uppergray);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// paddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpadding
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onMount } from "svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import TextButton from "/src/components/widgets/buttons/TextButton.svelte";
|
||||
import TextInput from "/src/components/widgets/inputs/TextInput.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { NodeGraphStore } from "/src/stores/node-graph";
|
||||
import type { FrontendNodeType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ selectNodeType: string }>();
|
||||
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
|
||||
|
||||
// Content
|
||||
export let disabled = false;
|
||||
// Behavior
|
||||
export let initialSearchTerm = "";
|
||||
|
||||
let nodeSearchInput: TextInput | undefined = undefined;
|
||||
let searchTerm = initialSearchTerm;
|
||||
|
||||
$: nodeCategories = buildNodeCategories(searchTerm);
|
||||
|
||||
type NodeCategoryDetails = {
|
||||
nodes: FrontendNodeType[];
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
function buildNodeCategories(searchTerm: string): [string, NodeCategoryDetails][] {
|
||||
const nodeTypes = $nodeGraph.nodeTypes;
|
||||
const categories = new SvelteMap<string, NodeCategoryDetails>();
|
||||
const isTypeSearch = searchTerm.toLowerCase().startsWith("type:");
|
||||
let typeSearchTerm = "";
|
||||
let remainingSearchTerms = [searchTerm.toLowerCase()];
|
||||
|
||||
if (isTypeSearch) {
|
||||
// Extract the first word after "type:" as the type search
|
||||
const searchParts = searchTerm.substring(5).trim().split(/\s+/);
|
||||
typeSearchTerm = searchParts[0].toLowerCase();
|
||||
|
||||
remainingSearchTerms = searchParts.slice(1).map((term) => term.toLowerCase());
|
||||
}
|
||||
|
||||
nodeTypes.forEach((node) => {
|
||||
let matchesTypeSearch = true;
|
||||
let matchesRemainingTerms = true;
|
||||
|
||||
if (isTypeSearch && typeSearchTerm) {
|
||||
matchesTypeSearch = node.inputTypes?.some((inputType) => inputType.toLowerCase().includes(typeSearchTerm)) || false;
|
||||
}
|
||||
|
||||
if (remainingSearchTerms.length > 0) {
|
||||
matchesRemainingTerms = remainingSearchTerms.every((term) => {
|
||||
const nameMatch = node.name.toLowerCase().includes(term);
|
||||
const categoryMatch = node.category.toLowerCase().includes(term);
|
||||
|
||||
// Quick and dirty hack to alias "Layer" to "Merge" in the search
|
||||
const layerAliasMatch = node.name === "Merge" && "layer".includes(term);
|
||||
|
||||
return nameMatch || categoryMatch || layerAliasMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// Node matches if it passes both type search and remaining terms filters
|
||||
const includesSearchTerm = matchesTypeSearch && matchesRemainingTerms;
|
||||
|
||||
if (searchTerm.length > 0 && !includesSearchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const category = categories.get(node.category);
|
||||
let open = includesSearchTerm;
|
||||
if (searchTerm.length === 0) {
|
||||
open = false;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
category.open = category.open || open;
|
||||
category.nodes.push(node);
|
||||
} else {
|
||||
categories.set(node.category, {
|
||||
open,
|
||||
nodes: [node],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const START_CATEGORIES_ORDER = ["UNCATEGORIZED", "General", "Value", "Math", "Style"];
|
||||
const END_CATEGORIES_ORDER = ["Debug"];
|
||||
return Array.from(categories)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.sort((a, b) => {
|
||||
const aIndex = START_CATEGORIES_ORDER.findIndex((x) => a[0].startsWith(x));
|
||||
const bIndex = START_CATEGORIES_ORDER.findIndex((x) => b[0].startsWith(x));
|
||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
return 0;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aIndex = END_CATEGORIES_ORDER.findIndex((x) => a[0].startsWith(x));
|
||||
const bIndex = END_CATEGORIES_ORDER.findIndex((x) => b[0].startsWith(x));
|
||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||
if (aIndex !== -1) return 1;
|
||||
if (bIndex !== -1) return -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => nodeSearchInput?.focus(), 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutCol class="node-catalog">
|
||||
<TextInput placeholder="Search Nodes…" value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
|
||||
<div class="list-results" on:wheel|passive|stopPropagation>
|
||||
{#each nodeCategories as nodeCategory}
|
||||
<details open={nodeCategory[1].open}>
|
||||
<summary>
|
||||
<TextLabel>{nodeCategory[0]}</TextLabel>
|
||||
</summary>
|
||||
{#each nodeCategory[1].nodes as nodeType}
|
||||
<TextButton
|
||||
{disabled}
|
||||
label={nodeType.name}
|
||||
tooltipLabel={nodeType.name}
|
||||
tooltipDescription={nodeType.identifier ? $nodeGraph.nodeDescriptions.get(nodeType.identifier) : undefined}
|
||||
action={() => dispatch("selectNodeType", nodeType.identifier)}
|
||||
/>
|
||||
{/each}
|
||||
</details>
|
||||
{:else}
|
||||
<TextLabel>No search results</TextLabel>
|
||||
{/each}
|
||||
</div>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.node-catalog {
|
||||
max-height: 30vh;
|
||||
min-width: 250px;
|
||||
|
||||
.text-input {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.list-results {
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
|
||||
details {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
|
||||
&[open] summary .text-label::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
summary {
|
||||
display: flex;
|
||||
|
||||
.text-label {
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
margin: auto;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--icon-expand-collapse-arrow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-button {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:last-child .text-button {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import FloatingMenu from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { TooltipStore } from "/src/stores/tooltip";
|
||||
import type { EditorWrapper, LabeledShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
|
||||
let self: FloatingMenu | undefined;
|
||||
|
||||
$: label = parseMarkdown(filterTodo($tooltip.element?.getAttribute("data-tooltip-label")?.trim()));
|
||||
$: description = parseMarkdown(filterTodo($tooltip.element?.getAttribute("data-tooltip-description")?.trim()));
|
||||
$: shortcutJSON = $tooltip.element?.getAttribute("data-tooltip-shortcut")?.trim();
|
||||
$: shortcut = ((shortcutJSON) => {
|
||||
if (!shortcutJSON) return undefined;
|
||||
try {
|
||||
const parsed: LabeledShortcut = JSON.parse(shortcutJSON);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})(shortcutJSON);
|
||||
|
||||
// TODO: Once all TODOs are replaced with real text, remove this function
|
||||
function filterTodo(text: string | undefined): string | undefined {
|
||||
if (text?.trim().toUpperCase() === "TODO" && !editor.inDevelopmentMode()) return "";
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseMarkdown(markdown: string | undefined): string | undefined {
|
||||
if (!markdown) return undefined;
|
||||
|
||||
let text = markdown.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
|
||||
return (
|
||||
text
|
||||
// .split("\n")
|
||||
// .map((line) => line.trim())
|
||||
// .join("\n")
|
||||
// .split("\n\n")
|
||||
// .map((paragraph) => paragraph.replaceAll("\n", " "))
|
||||
// .join("\n\n")
|
||||
// Bold
|
||||
.replace(/\*\*((?:(?!\*\*).)+)\*\*/g, "<strong>$1</strong>")
|
||||
// Italic
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
// Backticks
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if label || description}
|
||||
<div class="tooltip" style:top={`${$tooltip.position.y}px`} style:left={`${$tooltip.position.x}px`}>
|
||||
<FloatingMenu open={true} type="Tooltip" direction="Bottom" bind:this={self}>
|
||||
{#if label || shortcut}
|
||||
<LayoutRow class="tooltip-header">
|
||||
{#if label}
|
||||
<TextLabel class="tooltip-label">{@html label}</TextLabel>
|
||||
{/if}
|
||||
{#if shortcut}
|
||||
<ShortcutLabel shortcut={{ shortcut }} />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
{#if description}
|
||||
<TextLabel class="tooltip-description">{@html description}</TextLabel>
|
||||
{/if}
|
||||
</FloatingMenu>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
.floating-menu-content {
|
||||
max-width: Min(400px, 50vw);
|
||||
|
||||
.tooltip-header + .tooltip-description {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.text-label {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.text-label + .shortcut-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tooltip-description {
|
||||
color: var(--color-b-lightgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
export let condition: boolean;
|
||||
export let wrapperClass = "";
|
||||
</script>
|
||||
|
||||
{#if condition}
|
||||
<div class={wrapperClass}>
|
||||
<slot />
|
||||
</div>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -0,0 +1,732 @@
|
||||
<script lang="ts" context="module">
|
||||
/// Prevents the escape key from closing the parent floating menu of the given element.
|
||||
/// This works by momentarily setting the `data-escape-does-not-close` attribute on the parent floating menu element.
|
||||
/// After checking for the Escape key, it checks (in one `setTimeout`) for the attribute and ignores the key if it's present.
|
||||
/// Then after two calls of `setTimeout`, we can safely remove the attribute here.
|
||||
export function preventEscapeClosingParentFloatingMenu(element: HTMLElement) {
|
||||
const floatingMenuParent = element.closest("[data-floating-menu-content]") || undefined;
|
||||
if (floatingMenuParent instanceof HTMLElement) {
|
||||
floatingMenuParent.setAttribute("data-escape-does-not-close", "");
|
||||
setTimeout(() => {
|
||||
setTimeout(() => {
|
||||
floatingMenuParent.removeAttribute("data-escape-does-not-close");
|
||||
}, 0);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, afterUpdate, createEventDispatcher, tick } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import { browserVersion } from "/src/utility-functions/platform";
|
||||
import type { MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const BUTTON_LEFT = 0;
|
||||
const POINTER_STRAY_DISTANCE = 100;
|
||||
|
||||
const dispatch = createEventDispatcher<{ open: boolean; naturalWidth: number }>();
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let open: boolean;
|
||||
export let type: "Popover" | "Tooltip" | "Dropdown" | "Dialog" | "Cursor";
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
export let windowEdgeMargin = 6;
|
||||
export let scrollableY = false;
|
||||
export let minWidth = 0;
|
||||
export let escapeCloses = true;
|
||||
export let strayCloses = true;
|
||||
|
||||
let tail: HTMLDivElement | undefined;
|
||||
let self: HTMLDivElement | undefined;
|
||||
let floatingMenuContainer: HTMLDivElement | undefined;
|
||||
let floatingMenuContent: LayoutCol | undefined;
|
||||
|
||||
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
|
||||
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
|
||||
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
|
||||
// This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the
|
||||
// spawner widget to optionally set its min-size to the floating menu's natural width.
|
||||
const containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
resizeObserverCallback(entries);
|
||||
});
|
||||
|
||||
let dialogResizeObserver: ResizeObserver | undefined;
|
||||
let wasOpen = open;
|
||||
let measuringOngoing = false;
|
||||
let measuringOngoingGuard = false;
|
||||
let minWidthParentWidth = 0;
|
||||
let pointerStillDown = false;
|
||||
|
||||
$: watchOpenChange(open);
|
||||
|
||||
$: minWidthStyleValue = measuringOngoing ? "0" : `${Math.max(minWidth, minWidthParentWidth)}px`;
|
||||
$: displayTail = open && type === "Popover";
|
||||
$: displayContainer = open || measuringOngoing;
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
// Called only when `open` is changed from outside this component
|
||||
async function watchOpenChange(isOpen: boolean) {
|
||||
setSafariScrollableOverflow(isOpen);
|
||||
|
||||
// Switching from closed to open
|
||||
if (isOpen && !wasOpen) {
|
||||
// TODO: Close any other floating menus that may already be open, which can happen using tab navigation and Enter/Space Bar to open
|
||||
|
||||
// Close floating menu if pointer strays far enough away
|
||||
window.addEventListener("pointermove", pointerMoveHandler);
|
||||
// Close floating menu if esc is pressed
|
||||
window.addEventListener("keydown", keyDownHandler);
|
||||
// Close floating menu if pointer is outside (but within stray distance)
|
||||
window.addEventListener("pointerdown", pointerDownHandler);
|
||||
// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
|
||||
window.addEventListener("pointerup", pointerUpHandler);
|
||||
|
||||
// Floating menu min-width resize observer
|
||||
|
||||
await tick();
|
||||
|
||||
// Start a new observation of the now-open floating menu
|
||||
if (floatingMenuContainer) {
|
||||
containerResizeObserver.disconnect();
|
||||
containerResizeObserver.observe(floatingMenuContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// Switching from open to closed
|
||||
if (!isOpen && wasOpen) {
|
||||
// Clean up observation of the now-closed floating menu
|
||||
containerResizeObserver.disconnect();
|
||||
|
||||
window.removeEventListener("pointermove", pointerMoveHandler);
|
||||
window.removeEventListener("keydown", keyDownHandler);
|
||||
window.removeEventListener("pointerdown", pointerDownHandler);
|
||||
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
|
||||
}
|
||||
|
||||
// Now that we're done reading the old state, update it to the current state for next time
|
||||
wasOpen = isOpen;
|
||||
}
|
||||
|
||||
// Mitigate a Safari rendering bug which clips the floating menu extending beyond a scrollable container. The bug is possibly related to
|
||||
// <https://bugs.webkit.org/show_bug.cgi?id=160953>, but in our case it happens when `overflow` of a parent is `auto` rather than `hidden`.
|
||||
// The issue exists when the container is set to `overflow: auto` but fine when `overflow: hidden`. So this workaround temporarily sets
|
||||
// the scrollable container to `overflow: hidden`, thus removing the scrollbars and ability to scroll until the floating menu is closed.
|
||||
function setSafariScrollableOverflow(hidden: boolean) {
|
||||
if (!browserVersion().toLowerCase().includes("safari")) return;
|
||||
const scrollable = self?.closest("[data-scrollable-x], [data-scrollable-y]");
|
||||
if (scrollable instanceof HTMLElement) scrollable.style.overflow = hidden ? "hidden" : "";
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Measure the content and round up its width and height to the nearest even integer.
|
||||
// This solves antialiasing issues when the content isn't cleanly divisible by 2 and gets translated by (-50%, -50%) causing all its content to be blurry.
|
||||
const floatingMenuContentDiv = floatingMenuContent?.div?.();
|
||||
if (type === "Dialog" && floatingMenuContentDiv) {
|
||||
dialogResizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const existingWidth = Number(floatingMenuContentDiv.style.getPropertyValue("--even-integer-subpixel-expansion-x"));
|
||||
const existingHeight = Number(floatingMenuContentDiv.style.getPropertyValue("--even-integer-subpixel-expansion-y"));
|
||||
|
||||
let { width, height } = entry.contentRect;
|
||||
width -= existingWidth;
|
||||
height -= existingHeight;
|
||||
|
||||
let targetWidth = Math.ceil(width);
|
||||
if (targetWidth % 2 === 1) targetWidth += 1;
|
||||
let targetHeight = Math.ceil(height);
|
||||
if (targetHeight % 2 === 1) targetHeight += 1;
|
||||
|
||||
// We have to set the style properties directly because attempting to do it through a Svelte bound property results in `afterUpdate()` being triggered
|
||||
floatingMenuContentDiv.style.setProperty("--even-integer-subpixel-expansion-x", `${targetWidth - width}`);
|
||||
floatingMenuContentDiv.style.setProperty("--even-integer-subpixel-expansion-y", `${targetHeight - height}`);
|
||||
});
|
||||
});
|
||||
dialogResizeObserver.observe(floatingMenuContentDiv);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
containerResizeObserver.disconnect();
|
||||
dialogResizeObserver?.disconnect();
|
||||
window.removeEventListener("pointermove", pointerMoveHandler);
|
||||
window.removeEventListener("keydown", keyDownHandler);
|
||||
window.removeEventListener("pointerdown", pointerDownHandler);
|
||||
window.removeEventListener("pointerup", pointerUpHandler);
|
||||
window.removeEventListener("click", clickHandlerCapture, true);
|
||||
|
||||
// Revert Safari overflow workaround if the menu was open when destroyed
|
||||
if (open) setSafariScrollableOverflow(false);
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
// Gets the client bounds of the elements and apply relevant styles to them.
|
||||
// TODO: Use DOM attribute bindings more whilst not causing recursive updates. Turning measuring on and off both causes the component to change,
|
||||
// TODO: which causes the `afterUpdate()` Svelte event to fire extraneous times (hurting performance and sometimes causing an infinite loop).
|
||||
if (!measuringOngoingGuard) positionAndStyleFloatingMenu();
|
||||
});
|
||||
|
||||
function resizeObserverCallback(entries: ResizeObserverEntry[]) {
|
||||
minWidthParentWidth = entries[0].contentRect.width;
|
||||
}
|
||||
|
||||
function positionAndStyleFloatingMenu() {
|
||||
if (type === "Cursor") return;
|
||||
|
||||
const floatingMenuContentDiv = floatingMenuContent?.div?.();
|
||||
if (!self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;
|
||||
|
||||
const windowBounds = document.documentElement.getBoundingClientRect();
|
||||
const floatingMenuBounds = self.getBoundingClientRect();
|
||||
const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect();
|
||||
const floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect();
|
||||
|
||||
const overflowingLeft = floatingMenuContentBounds.left - windowEdgeMargin <= windowBounds.left;
|
||||
const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= windowBounds.right;
|
||||
const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= windowBounds.top;
|
||||
const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= windowBounds.bottom;
|
||||
|
||||
// TODO: Make this work for all types. This is currently limited to tooltips because they're inherently small and transient.
|
||||
// TODO: But on popovers and dropdowns, it's a bit harder to do this right. First we check if it's overflowing and flip the direction to avoid the overflow.
|
||||
// TODO: But once it's flipped, if the position moves and the menu would no longer be overflowing, we're still flipped and thus unable to automatically notice the need to flip back.
|
||||
// TODO: So as a result, once flipped, it stays flipped forever even if the menu spawner element is moved back away from the edge of the window.
|
||||
if (type === "Tooltip") {
|
||||
// Flip direction if overflowing the edge of the window
|
||||
if (direction === "Top" && overflowingTop) direction = "Bottom";
|
||||
else if (direction === "Bottom" && overflowingBottom) direction = "Top";
|
||||
else if (direction === "Left" && overflowingLeft) direction = "Right";
|
||||
else if (direction === "Right" && overflowingRight) direction = "Left";
|
||||
}
|
||||
|
||||
// These are set imperatively, not through reactive Svelte style bindings, because that would cause `afterUpdate()` to call this function recursively forever.
|
||||
// CSS custom properties on the container are used instead of direct `.style` on the content because Svelte's `set_style` can replace the content's entire
|
||||
// inline style when its managed `style` attribute updates, which would wipe out any manually-set properties like `top` and `left`.
|
||||
// We set each property to `initial` rather than removing it, because CSS custom properties inherit, so a submenu nested inside a parent floating menu would
|
||||
// otherwise pick up the parent's positioning values. Setting to `initial` makes `var()` fall back to its default on this element regardless of inheritance.
|
||||
floatingMenuContainer.style.setProperty("--content-top", "initial");
|
||||
floatingMenuContainer.style.setProperty("--content-bottom", "initial");
|
||||
floatingMenuContainer.style.setProperty("--content-left", "initial");
|
||||
floatingMenuContainer.style.setProperty("--content-right", "initial");
|
||||
floatingMenuContainer.style.setProperty("--content-border-top-left-radius", "initial");
|
||||
floatingMenuContainer.style.setProperty("--content-border-top-right-radius", "initial");
|
||||
floatingMenuContainer.style.setProperty("--content-border-bottom-left-radius", "initial");
|
||||
floatingMenuContainer.style.setProperty("--content-border-bottom-right-radius", "initial");
|
||||
|
||||
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
|
||||
const noPosition = Boolean(floatingMenuContainer.closest("[data-floating-menu-no-position]"));
|
||||
if (!inParentFloatingMenu && !noPosition) {
|
||||
let tailOffset = 0;
|
||||
if (type === "Popover") tailOffset = 10;
|
||||
if (type === "Tooltip") tailOffset = direction === "Bottom" ? 20 : 10;
|
||||
|
||||
if (direction === "Bottom") floatingMenuContainer.style.setProperty("--content-top", `${tailOffset + floatingMenuBounds.y}px`);
|
||||
if (direction === "Top") floatingMenuContainer.style.setProperty("--content-bottom", `${tailOffset + (windowBounds.height - floatingMenuBounds.y)}px`);
|
||||
if (direction === "Right") floatingMenuContainer.style.setProperty("--content-left", `${tailOffset + floatingMenuBounds.x}px`);
|
||||
if (direction === "Left") floatingMenuContainer.style.setProperty("--content-right", `${tailOffset + (windowBounds.width - floatingMenuBounds.x)}px`);
|
||||
|
||||
if (tail && direction === "Bottom") tail.style.top = `${floatingMenuBounds.y}px`;
|
||||
if (tail && direction === "Top") tail.style.bottom = `${windowBounds.height - floatingMenuBounds.y}px`;
|
||||
if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.x}px`;
|
||||
if (tail && direction === "Left") tail.style.right = `${windowBounds.width - floatingMenuBounds.x}px`;
|
||||
}
|
||||
|
||||
type Edge = "Top" | "Bottom" | "Left" | "Right";
|
||||
let zeroedBorderVertical: Edge | undefined;
|
||||
let zeroedBorderHorizontal: Edge | undefined;
|
||||
|
||||
if (direction === "Top" || direction === "Bottom") {
|
||||
zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top";
|
||||
|
||||
if (overflowingLeft) {
|
||||
floatingMenuContainer.style.setProperty("--content-left", `${windowEdgeMargin}px`);
|
||||
if (windowBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
|
||||
}
|
||||
if (overflowingRight) {
|
||||
floatingMenuContainer.style.setProperty("--content-right", `${windowEdgeMargin}px`);
|
||||
if (windowBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right";
|
||||
}
|
||||
}
|
||||
if (direction === "Left" || direction === "Right") {
|
||||
zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left";
|
||||
|
||||
if (overflowingTop) {
|
||||
floatingMenuContainer.style.setProperty("--content-top", `${windowEdgeMargin}px`);
|
||||
if (windowBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
|
||||
}
|
||||
if (overflowingBottom) {
|
||||
floatingMenuContainer.style.setProperty("--content-bottom", `${windowEdgeMargin}px`);
|
||||
if (windowBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom";
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the rounded corner from the content where the tail perfectly meets the corner
|
||||
if (displayTail && windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
|
||||
switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) {
|
||||
case "TopLeft":
|
||||
floatingMenuContainer.style.setProperty("--content-border-top-left-radius", "0");
|
||||
break;
|
||||
case "TopRight":
|
||||
floatingMenuContainer.style.setProperty("--content-border-top-right-radius", "0");
|
||||
break;
|
||||
case "BottomLeft":
|
||||
floatingMenuContainer.style.setProperty("--content-border-bottom-left-radius", "0");
|
||||
break;
|
||||
case "BottomRight":
|
||||
floatingMenuContainer.style.setProperty("--content-border-bottom-right-radius", "0");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function div(): HTMLDivElement | undefined {
|
||||
return self;
|
||||
}
|
||||
|
||||
// To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
|
||||
export async function measureAndEmitNaturalWidth() {
|
||||
if (!measuringOngoingGuard) return;
|
||||
|
||||
// Wait for the changed content which fired the `afterUpdate()` Svelte event to be put into the DOM
|
||||
await tick();
|
||||
|
||||
// Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate
|
||||
await document.fonts.ready;
|
||||
|
||||
// Make the component show itself with 0 min-width so it can be measured, and wait until the values have been updated to the DOM
|
||||
measuringOngoing = true;
|
||||
measuringOngoingGuard = true;
|
||||
await tick();
|
||||
|
||||
// Measure the width of the floating menu content element, if it's currently visible
|
||||
// The result will be `undefined` if the menu is invisible, perhaps because an ancestor component is hidden with a falsy Svelte template if condition
|
||||
const naturalWidth: number | undefined = floatingMenuContent?.div?.()?.clientWidth;
|
||||
|
||||
// Turn off measuring mode for the component, which triggers another call to the `afterUpdate()` Svelte event, so we can turn off the protection after that has happened
|
||||
measuringOngoing = false;
|
||||
await tick();
|
||||
measuringOngoingGuard = false;
|
||||
|
||||
// Notify the parent about the measured natural width
|
||||
if (naturalWidth !== undefined && naturalWidth >= 0) {
|
||||
dispatch("naturalWidth", naturalWidth);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerMoveHandler(e: PointerEvent) {
|
||||
// This element and the element being hovered over
|
||||
const target = e.target instanceof HTMLElement ? e.target : undefined;
|
||||
|
||||
// Get the spawner element (that which is clicked to spawn this floating menu)
|
||||
// Assumes the spawner is a sibling of this FloatingMenu component
|
||||
const ownSpawner: HTMLElement | undefined = self?.parentElement?.querySelector(":scope > [data-floating-menu-spawner]") || undefined;
|
||||
// Get the spawner element containing whatever element the user is hovering over now, if there is one
|
||||
const targetSpawner: HTMLElement | undefined = target?.closest?.("[data-floating-menu-spawner]") || undefined;
|
||||
|
||||
// HOVER TRANSFER
|
||||
// Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner
|
||||
if (strayCloses) hoverTransfer(self, ownSpawner, targetSpawner);
|
||||
|
||||
// POINTER STRAY
|
||||
// Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner)
|
||||
const notHoveringOverOwnSpawner = ownSpawner !== targetSpawner;
|
||||
if (strayCloses && notHoveringOverOwnSpawner && isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE)) {
|
||||
// TODO: Extend this rectangle bounds check to all submenu bounds up the DOM tree since currently submenus disappear
|
||||
// TODO: with zero stray distance if the cursor is further than the stray distance from only the top-level menu
|
||||
dispatch("open", false);
|
||||
}
|
||||
|
||||
// Clean up any messes from lost pointerup events
|
||||
const BUTTONS_LEFT = 0b0000_0001;
|
||||
const eventIncludesLmb = Boolean(e.buttons & BUTTONS_LEFT);
|
||||
if (!open && !eventIncludesLmb) {
|
||||
pointerStillDown = false;
|
||||
window.removeEventListener("pointerup", pointerUpHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function hoverTransfer(self: HTMLDivElement | undefined, ownSpawner: HTMLElement | undefined, targetSpawner: HTMLElement | undefined) {
|
||||
// Algorithm pseudo-code to detect and transfer to hover-transferrable floating menu spawners
|
||||
// Accompanying diagram: <https://files.keavon.com/-/SpringgreenKnownXantus/capture.png>
|
||||
//
|
||||
// Check our own parent for descendant spawners
|
||||
// Filter out ourself and our children
|
||||
// Filter out all with a different distance than our own distance from the currently-being-checked parent
|
||||
// How many left?
|
||||
// None -> go up a level and repeat
|
||||
// Some -> is one of them the target?
|
||||
// Yes -> click it and terminate
|
||||
// No -> do nothing and terminate
|
||||
|
||||
// Helper function that gets used below
|
||||
const getDepthFromAncestor = (item: Element, ancestor: Element): number | undefined => {
|
||||
let depth = 1;
|
||||
|
||||
let parent = item.parentElement || undefined;
|
||||
while (parent) {
|
||||
if (parent === ancestor) return depth;
|
||||
|
||||
parent = parent.parentElement || undefined;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// A list of all the descendant spawners: the spawner for this floating menu plus any spawners belonging to widgets inside this floating menu
|
||||
const ownDescendantMenuSpawners = Array.from(self?.parentElement?.querySelectorAll("[data-floating-menu-spawner]") || []);
|
||||
|
||||
// Start with the parent of the spawner for this floating menu and keep widening the search for any other valid spawners that are hover-transferrable
|
||||
let currentAncestor = (targetSpawner && ownSpawner?.parentElement) || undefined;
|
||||
while (currentAncestor) {
|
||||
// If the current ancestor blocks hover transfer, stop searching
|
||||
if (currentAncestor.hasAttribute("data-block-hover-transfer")) break;
|
||||
|
||||
const ownSpawnerDepthFromCurrentAncestor = ownSpawner && getDepthFromAncestor(ownSpawner, currentAncestor);
|
||||
const currentAncestor2 = currentAncestor; // This duplicate variable avoids an ESLint warning
|
||||
|
||||
// Get the list of descendant spawners and filter out invalid possibilities for spawners that are hover-transferrable
|
||||
const listOfDescendantSpawners = Array.from(currentAncestor?.querySelectorAll("[data-floating-menu-spawner]") || []);
|
||||
const filteredListOfDescendantSpawners = listOfDescendantSpawners.filter((item: Element): boolean => {
|
||||
// Filter away ourself and our descendants
|
||||
const notOurself = !ownDescendantMenuSpawners.includes(item);
|
||||
// And filter away unequal depths from the current ancestor
|
||||
const notUnequalDepths = notOurself && getDepthFromAncestor(item, currentAncestor2) === ownSpawnerDepthFromCurrentAncestor;
|
||||
// And filter away descendants that explicitly disable hover transfer
|
||||
return notUnequalDepths && !(item instanceof HTMLElement && item.hasAttribute("data-block-hover-transfer"));
|
||||
});
|
||||
|
||||
// If none were found, widen the search by a level and keep trying (or stop looping if the root was reached)
|
||||
if (filteredListOfDescendantSpawners.length === 0) {
|
||||
currentAncestor = currentAncestor?.parentElement || undefined;
|
||||
}
|
||||
// Stop after the first non-empty set was found
|
||||
else {
|
||||
const foundTarget = filteredListOfDescendantSpawners.find((item: Element): boolean => item === targetSpawner);
|
||||
// If the currently hovered spawner is one of the found valid hover-transferrable spawners, swap to it by clicking on it
|
||||
if (foundTarget instanceof HTMLElement) {
|
||||
dispatch("open", false);
|
||||
foundTarget.click();
|
||||
}
|
||||
|
||||
// In either case, we are done searching
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keyDownHandler(e: KeyboardEvent) {
|
||||
if (escapeCloses && e.key === "Escape") {
|
||||
setTimeout(() => {
|
||||
if (!floatingMenuContainer?.querySelector("[data-floating-menu-content][data-escape-does-not-close]")) {
|
||||
dispatch("open", false);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Find the parent floating menu and prevent it from also closing with the escape key when this floating menu does
|
||||
if (self) preventEscapeClosingParentFloatingMenu(self);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerDownHandler(e: PointerEvent) {
|
||||
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
|
||||
if (isPointerEventOutsideFloatingMenu(e)) {
|
||||
dispatch("open", false);
|
||||
|
||||
// Track if the left pointer button is now down so its later click event can be canceled
|
||||
const eventIsForLmb = e.button === BUTTON_LEFT;
|
||||
if (eventIsForLmb) pointerStillDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
function pointerUpHandler(e: PointerEvent) {
|
||||
const eventIsForLmb = e.button === BUTTON_LEFT;
|
||||
if (pointerStillDown && eventIsForLmb) {
|
||||
// Clean up self
|
||||
pointerStillDown = false;
|
||||
window.removeEventListener("pointerup", pointerUpHandler);
|
||||
// Prevent the click event from firing, which would normally occur right after this pointerup event
|
||||
window.addEventListener("click", clickHandlerCapture, true);
|
||||
}
|
||||
}
|
||||
|
||||
function clickHandlerCapture(e: MouseEvent) {
|
||||
// Stop the click event from reopening this floating menu if the click event targets the floating menu's button
|
||||
e.stopPropagation();
|
||||
// Clean up self
|
||||
window.removeEventListener("click", clickHandlerCapture, true);
|
||||
}
|
||||
|
||||
function isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
|
||||
// Consider all child menus as well as the top-level one
|
||||
const allContainedFloatingMenus = [...(self?.querySelectorAll("[data-floating-menu-content]") || [])];
|
||||
|
||||
return !allContainedFloatingMenus.find((element) => !isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed));
|
||||
}
|
||||
|
||||
function isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {
|
||||
const floatingMenuBounds = element.getBoundingClientRect();
|
||||
|
||||
if (floatingMenuBounds.left - e.clientX >= extraDistanceAllowed) return true;
|
||||
if (e.clientX - floatingMenuBounds.right >= extraDistanceAllowed) return true;
|
||||
if (floatingMenuBounds.top - e.clientY >= extraDistanceAllowed) return true;
|
||||
if (e.clientY - floatingMenuBounds.bottom >= extraDistanceAllowed) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`floating-menu ${direction.toLowerCase()} ${type.toLowerCase()} ${className} ${extraClasses}`.trim()}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
bind:this={self}
|
||||
{...$$restProps}
|
||||
>
|
||||
{#if displayTail}
|
||||
<div class="tail" bind:this={tail}></div>
|
||||
{/if}
|
||||
{#if displayContainer}
|
||||
<div class="floating-menu-container" bind:this={floatingMenuContainer} on:wheel|stopPropagation>
|
||||
<LayoutCol class="floating-menu-content" styles={{ "min-width": minWidthStyleValue }} {scrollableY} bind:this={floatingMenuContent} data-floating-menu-content>
|
||||
<slot />
|
||||
</LayoutCol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.floating-menu {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: flex;
|
||||
// Floating menus begin at a z-index of 1000
|
||||
z-index: 1000;
|
||||
--floating-menu-content-offset: 0;
|
||||
|
||||
.tail {
|
||||
// Put the tail above the floating menu's shadow
|
||||
z-index: 10;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
|
||||
&,
|
||||
&::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-menu-container {
|
||||
display: flex;
|
||||
|
||||
.floating-menu-content {
|
||||
background: var(--color-2-mildblack);
|
||||
box-shadow: rgba(var(--color-0-black-rgb), 0.5) 0 2px 4px;
|
||||
border: 1px solid var(--color-3-darkgray);
|
||||
border-radius: 4px;
|
||||
border-top-left-radius: var(--content-border-top-left-radius, 4px);
|
||||
border-top-right-radius: var(--content-border-top-right-radius, 4px);
|
||||
border-bottom-left-radius: var(--content-border-bottom-left-radius, 4px);
|
||||
border-bottom-right-radius: var(--content-border-bottom-right-radius, 4px);
|
||||
color: var(--color-e-nearwhite);
|
||||
font-size: inherit;
|
||||
padding: 8px;
|
||||
z-index: 0;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
top: var(--content-top, auto);
|
||||
bottom: var(--content-bottom, auto);
|
||||
left: var(--content-left, auto);
|
||||
right: var(--content-right, auto);
|
||||
// Counteract the rightward shift caused by the border
|
||||
margin-left: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
&.dropdown {
|
||||
&.top {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.left {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.right {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.topleft {
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
&.topright {
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
&.topleft {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
&.topright {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.top.dropdown .floating-menu-container,
|
||||
&.bottom.dropdown .floating-menu-container {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
&.popover {
|
||||
--floating-menu-content-offset: 10px;
|
||||
}
|
||||
|
||||
&.cursor .floating-menu-container .floating-menu-content {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> .floating-menu-container > .floating-menu-content {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.top,
|
||||
&.bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.top .tail,
|
||||
&.topleft .tail,
|
||||
&.topright .tail {
|
||||
border-color: var(--color-3-darkgray) transparent transparent transparent;
|
||||
|
||||
&::before {
|
||||
border-color: var(--color-2-mildblack) transparent transparent transparent;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&,
|
||||
&::before {
|
||||
border-width: 8px 6px 0 6px;
|
||||
margin-left: -6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom .tail,
|
||||
&.bottomleft .tail,
|
||||
&.bottomright .tail {
|
||||
border-color: transparent transparent var(--color-3-darkgray) transparent;
|
||||
|
||||
&::before {
|
||||
border-color: transparent transparent var(--color-2-mildblack) transparent;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&,
|
||||
&::before {
|
||||
border-width: 0 6px 8px 6px;
|
||||
margin-left: -6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.left .tail {
|
||||
border-color: transparent transparent transparent var(--color-3-darkgray);
|
||||
|
||||
&::before {
|
||||
border-color: transparent transparent transparent var(--color-2-mildblack);
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&,
|
||||
&::before {
|
||||
border-width: 6px 0 6px 8px;
|
||||
margin-top: -6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.right .tail {
|
||||
border-color: transparent var(--color-3-darkgray) transparent transparent;
|
||||
|
||||
&::before {
|
||||
border-color: transparent var(--color-2-mildblack) transparent transparent;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&,
|
||||
&::before {
|
||||
border-width: 6px 8px 6px 0;
|
||||
margin-top: -6px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.top .floating-menu-container {
|
||||
justify-content: center;
|
||||
margin-bottom: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.bottom .floating-menu-container {
|
||||
justify-content: center;
|
||||
margin-top: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.left .floating-menu-container {
|
||||
align-items: center;
|
||||
margin-right: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.right .floating-menu-container {
|
||||
align-items: center;
|
||||
margin-left: var(--floating-menu-content-offset);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// TODO: Add middle-click drag scrolling
|
||||
export let scrollableX = false;
|
||||
export let scrollableY = false;
|
||||
|
||||
let self: HTMLDivElement | undefined;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
export function div(): HTMLDivElement | undefined {
|
||||
return self;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Excluded events because these require `|passive` or `|nonpassive` modifiers. Use a <div> for these instead: `on:wheel`, `on:touchmove`, `on:touchstart` -->
|
||||
<div
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
data-scrollable-x={scrollableX ? "" : undefined}
|
||||
data-scrollable-y={scrollableY ? "" : undefined}
|
||||
class={`layout-col ${className} ${extraClasses}`.trim()}
|
||||
class:scrollable-x={scrollableX}
|
||||
class:scrollable-y={scrollableY}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
bind:this={self}
|
||||
on:auxclick
|
||||
on:blur
|
||||
on:click
|
||||
on:dblclick
|
||||
on:dragend
|
||||
on:dragleave
|
||||
on:dragover
|
||||
on:dragstart
|
||||
on:drop
|
||||
on:mouseup
|
||||
on:pointerdown
|
||||
on:pointerenter
|
||||
on:pointerleave
|
||||
on:scroll
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Unused (each impacts performance, see <https://github.com/GraphiteEditor/Graphite/issues/1877>):
|
||||
on:contextmenu
|
||||
on:copy
|
||||
on:cut
|
||||
on:drag
|
||||
on:dragenter
|
||||
on:focus
|
||||
on:fullscreenchange
|
||||
on:fullscreenerror
|
||||
on:gotpointercapture
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:lostpointercapture
|
||||
on:mousedown
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:mousemove
|
||||
on:mouseout
|
||||
on:mouseover
|
||||
on:paste
|
||||
on:pointercancel
|
||||
on:pointermove
|
||||
on:pointerout
|
||||
on:pointerover
|
||||
on:pointerup
|
||||
on:select
|
||||
on:touchcancel
|
||||
on:touchend
|
||||
-->
|
||||
|
||||
<style lang="scss">
|
||||
.layout-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// TODO: Add middle-click drag scrolling
|
||||
export let scrollableX = false;
|
||||
export let scrollableY = false;
|
||||
|
||||
let self: HTMLDivElement | undefined;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
export function div(): HTMLDivElement | undefined {
|
||||
return self;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Excluded events because these require `|passive` or `|nonpassive` modifiers. Use a <div> for these instead: `on:wheel`, `on:touchmove`, `on:touchstart` -->
|
||||
<div
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
data-scrollable-x={scrollableX ? "" : undefined}
|
||||
data-scrollable-y={scrollableY ? "" : undefined}
|
||||
class={`layout-row ${className} ${extraClasses}`.trim()}
|
||||
class:scrollable-x={scrollableX}
|
||||
class:scrollable-y={scrollableY}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
bind:this={self}
|
||||
on:auxclick
|
||||
on:blur
|
||||
on:click
|
||||
on:dblclick
|
||||
on:dragend
|
||||
on:dragleave
|
||||
on:dragover
|
||||
on:dragstart
|
||||
on:drop
|
||||
on:mousedown
|
||||
on:mouseup
|
||||
on:pointerdown
|
||||
on:pointerenter
|
||||
on:pointerleave
|
||||
on:scroll
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Unused (each impacts performance, see <https://github.com/GraphiteEditor/Graphite/issues/1877>):
|
||||
on:contextmenu
|
||||
on:copy
|
||||
on:cut
|
||||
on:drag
|
||||
on:dragenter
|
||||
on:focus
|
||||
on:fullscreenchange
|
||||
on:fullscreenerror
|
||||
on:gotpointercapture
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:lostpointercapture
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:mousemove
|
||||
on:mouseout
|
||||
on:mouseover
|
||||
on:paste
|
||||
on:pointercancel
|
||||
on:pointermove
|
||||
on:pointerout
|
||||
on:pointerover
|
||||
on:pointerup
|
||||
on:select
|
||||
on:touchcancel
|
||||
on:touchend
|
||||
-->
|
||||
|
||||
<style lang="scss">
|
||||
.layout-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import { dataPanelLayout } from "/src/stores/portfolio";
|
||||
</script>
|
||||
|
||||
<LayoutCol class="data-panel">
|
||||
<LayoutCol class="body" scrollableY={true}>
|
||||
<WidgetLayout layout={$dataPanelLayout} layoutTarget="DataPanel" />
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.data-panel {
|
||||
flex-grow: 1;
|
||||
padding: 4px;
|
||||
padding-top: 0;
|
||||
|
||||
table {
|
||||
margin: -4px;
|
||||
width: calc(100% + 2 * 4px);
|
||||
|
||||
.text-label {
|
||||
white-space: wrap;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
tr:first-child:has(td:first-child label:empty) ~ tr td:first-child {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.widget-span:has(.text-area-input) {
|
||||
flex: 1 1 100%;
|
||||
|
||||
.text-area-input {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
|
||||
textarea {
|
||||
height: 100%;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,947 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy, tick } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import { createDragToggleManager, destroyDragToggleManager } from "/src/managers/drag-toggle";
|
||||
import type { NodeGraphStore } from "/src/stores/node-graph";
|
||||
import { layersPanelControlBarLeftLayout, layersPanelControlBarRightLayout, layersPanelBottomBarLayout } from "/src/stores/portfolio";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import type { TooltipStore } from "/src/stores/tooltip";
|
||||
import { pasteFile } from "/src/utility-functions/files";
|
||||
import { operatingSystem } from "/src/utility-functions/platform";
|
||||
import type { EditorWrapper, LayerPanelEntry, LayerStructureEntry } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
type LayerListingInfo = {
|
||||
folderIndex: number;
|
||||
bottomLayer: boolean;
|
||||
editingName: boolean;
|
||||
entry: LayerPanelEntry;
|
||||
depth: number;
|
||||
parentId: bigint | undefined;
|
||||
childrenPresent: boolean;
|
||||
expanded: boolean;
|
||||
ancestorOfSelected: boolean;
|
||||
parentsVisible: boolean;
|
||||
parentsUnlocked: boolean;
|
||||
treePath: bigint[];
|
||||
};
|
||||
|
||||
type DraggingData = {
|
||||
select?: () => void;
|
||||
insertParentId: bigint | undefined;
|
||||
insertDepth: number;
|
||||
insertIndex: number | undefined;
|
||||
highlightFolder: boolean;
|
||||
highlightFolderIndex: number | undefined;
|
||||
markerHeight: number;
|
||||
};
|
||||
|
||||
type InternalDragState = {
|
||||
active: boolean;
|
||||
layerId: bigint;
|
||||
listing: LayerListingInfo;
|
||||
startX: number;
|
||||
startY: number;
|
||||
};
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
|
||||
let list: LayoutCol | undefined;
|
||||
|
||||
let layers: LayerListingInfo[] = [];
|
||||
|
||||
// Interactive dragging
|
||||
let draggable = true;
|
||||
let draggingData: undefined | DraggingData = undefined;
|
||||
let internalDragState: InternalDragState | undefined = undefined;
|
||||
let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined;
|
||||
let justFinishedDrag = false; // Used to prevent click events after a drag
|
||||
let dragInPanel = false;
|
||||
let dragDropTarget: HTMLElement | undefined = undefined;
|
||||
|
||||
// Interactive clipping
|
||||
let layerToClipUponClick: LayerListingInfo | undefined = undefined;
|
||||
let layerToClipAltKeyPressed = false;
|
||||
|
||||
// Drag-toggle: tracked here so the template can render the invisible lock placeholder during a `layer-lock` gesture
|
||||
let activeDragToggleGroup: string | undefined = undefined;
|
||||
|
||||
$: rebuildLayerHierarchy($portfolio.layerStructure, $portfolio.layerCache);
|
||||
|
||||
onMount(() => {
|
||||
createDragToggleManager(dragToggleListener);
|
||||
|
||||
addEventListener("pointerup", draggingPointerUp);
|
||||
addEventListener("pointermove", draggingPointerMove);
|
||||
addEventListener("mousedown", draggingMouseDown);
|
||||
addEventListener("keydown", draggingKeyDown);
|
||||
addEventListener("keydown", handleLayerPanelKeyDown);
|
||||
|
||||
addEventListener("pointermove", clippingHover);
|
||||
addEventListener("keydown", clippingKeyPress);
|
||||
addEventListener("keyup", clippingKeyPress);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyDragToggleManager(dragToggleListener);
|
||||
|
||||
removeEventListener("pointerup", draggingPointerUp);
|
||||
removeEventListener("pointermove", draggingPointerMove);
|
||||
removeEventListener("mousedown", draggingMouseDown);
|
||||
removeEventListener("keydown", draggingKeyDown);
|
||||
removeEventListener("keydown", handleLayerPanelKeyDown);
|
||||
|
||||
removeEventListener("pointermove", clippingHover);
|
||||
removeEventListener("keydown", clippingKeyPress);
|
||||
removeEventListener("keyup", clippingKeyPress);
|
||||
});
|
||||
|
||||
function dragToggleListener(group: string | undefined) {
|
||||
activeDragToggleGroup = group;
|
||||
}
|
||||
|
||||
function toggleNodeVisibilityLayerPanel(id: bigint) {
|
||||
editor.toggleNodeVisibilityLayerPanel(id);
|
||||
}
|
||||
|
||||
function toggleLayerLock(id: bigint) {
|
||||
editor.toggleLayerLock(id);
|
||||
}
|
||||
|
||||
function handleExpandArrowClickWithModifiers(e: MouseEvent, treePath: bigint[]) {
|
||||
const accel = operatingSystem() === "Mac" ? e.metaKey : e.ctrlKey;
|
||||
const collapseRecursive = e.altKey || accel;
|
||||
editor.toggleLayerExpansion(BigUint64Array.from(treePath), collapseRecursive);
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
async function onEditLayerName(listing: LayerListingInfo) {
|
||||
if (listing.editingName) return;
|
||||
|
||||
draggable = false;
|
||||
listing.editingName = true;
|
||||
layers = layers;
|
||||
|
||||
await tick();
|
||||
|
||||
const query = list?.div?.()?.querySelector("[data-text-input]:not([disabled])");
|
||||
const textInput = (query instanceof HTMLInputElement && query) || undefined;
|
||||
textInput?.select();
|
||||
}
|
||||
|
||||
function onEditLayerNameChange(listing: LayerListingInfo, e: Event) {
|
||||
// Eliminate duplicate events
|
||||
if (!listing.editingName) return;
|
||||
|
||||
draggable = true;
|
||||
listing.editingName = false;
|
||||
layers = layers;
|
||||
|
||||
const name = (e.target instanceof HTMLInputElement && e.target.value) || "";
|
||||
editor.setLayerName(listing.entry.id, name);
|
||||
listing.entry.alias = name;
|
||||
}
|
||||
|
||||
async function onEditLayerNameDeselect(listing: LayerListingInfo) {
|
||||
draggable = true;
|
||||
listing.editingName = false;
|
||||
layers = layers;
|
||||
|
||||
// Set it back to the original name if the user didn't enter a new name
|
||||
if (document.activeElement instanceof HTMLInputElement) document.activeElement.value = listing.entry.alias;
|
||||
|
||||
// Deselect the text so it doesn't appear selected while the input field becomes disabled and styled to look like regular text
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
function selectLayerWithModifiers(e: MouseEvent, listing: LayerListingInfo) {
|
||||
if (justFinishedDrag) {
|
||||
justFinishedDrag = false;
|
||||
// Prevent bubbling to deselectAllLayers
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the pressed state of the modifier keys
|
||||
const [ctrl, meta, shift, alt] = [e.ctrlKey, e.metaKey, e.shiftKey, e.altKey];
|
||||
// Get the state of the platform's accel key and its opposite platform's accel key
|
||||
const [accel, oppositeAccel] = operatingSystem() === "Mac" ? [meta, ctrl] : [ctrl, meta];
|
||||
|
||||
// Alt-clicking to make a clipping mask
|
||||
if (layerToClipAltKeyPressed && layerToClipUponClick && layerToClipUponClick.entry.clippable) clipLayer(layerToClipUponClick);
|
||||
// Select the layer only if the accel and/or shift keys are pressed
|
||||
else if (!oppositeAccel && !alt) selectLayer(listing, accel, shift);
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function clipLayer(listing: LayerListingInfo) {
|
||||
editor.clipLayer(listing.entry.id);
|
||||
}
|
||||
|
||||
function clippingKeyPress(e: KeyboardEvent) {
|
||||
layerToClipAltKeyPressed = e.altKey;
|
||||
}
|
||||
|
||||
function clippingHover(e: PointerEvent) {
|
||||
// Don't do anything if the user is dragging to rearrange layers
|
||||
if (dragInPanel) return;
|
||||
|
||||
// Get the layer below the cursor
|
||||
const target = (e.target instanceof HTMLElement && e.target.closest("[data-layer]")) || undefined;
|
||||
if (!target) {
|
||||
layerToClipUponClick = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the cursor is near the border between two layers
|
||||
const DISTANCE = 6;
|
||||
const distanceFromTop = e.clientY - target.getBoundingClientRect().top;
|
||||
const distanceFromBottom = target.getBoundingClientRect().bottom - e.clientY;
|
||||
|
||||
const nearTop = distanceFromTop < DISTANCE;
|
||||
const nearBottom = distanceFromBottom < DISTANCE;
|
||||
|
||||
// If we are not near the border, we don't want to clip
|
||||
if (!nearTop && !nearBottom) {
|
||||
layerToClipUponClick = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are near the border, we want to clip the layer above the border
|
||||
const indexAttribute = target?.getAttribute("data-index") ?? undefined;
|
||||
const index = indexAttribute ? Number(indexAttribute) : undefined;
|
||||
const layer = index !== undefined && layers[nearTop ? index - 1 : index];
|
||||
if (!layer) return;
|
||||
|
||||
// Update the state used to show the clipping action
|
||||
layerToClipUponClick = layer;
|
||||
layerToClipAltKeyPressed = e.altKey;
|
||||
}
|
||||
|
||||
function selectLayer(listing: LayerListingInfo, accel: boolean, shift: boolean) {
|
||||
// Don't select while we are entering text to rename the layer
|
||||
if (listing.editingName) return;
|
||||
|
||||
editor.selectLayer(listing.entry.id, accel, shift);
|
||||
}
|
||||
|
||||
async function deselectAllLayers() {
|
||||
if (justFinishedDrag) {
|
||||
justFinishedDrag = false;
|
||||
return;
|
||||
}
|
||||
|
||||
editor.deselectAllLayers();
|
||||
}
|
||||
|
||||
function calculateDragIndex(tree: LayoutCol, clientY: number, select?: () => void): DraggingData {
|
||||
const treeChildren = tree.div()?.children;
|
||||
const treeOffset = tree.div()?.getBoundingClientRect().top;
|
||||
|
||||
// Folder to insert into
|
||||
let insertParentId: bigint | undefined = undefined;
|
||||
let insertDepth = 0;
|
||||
|
||||
// Insert index (starts at the end, essentially infinity)
|
||||
let insertIndex = undefined;
|
||||
|
||||
// Whether you are inserting into a folder and should show the folder outline
|
||||
let highlightFolder = false;
|
||||
let highlightFolderIndex: number | undefined = undefined;
|
||||
|
||||
let markerHeight = 0;
|
||||
const layerPanel = document.querySelector("[data-layer-panel]"); // Selects the element with the data-layer-panel attribute
|
||||
if (layerPanel && treeChildren && treeOffset !== undefined) {
|
||||
let layerPanelTop = layerPanel.getBoundingClientRect().top;
|
||||
Array.from(treeChildren).forEach((treeChild) => {
|
||||
const indexAttribute = treeChild.getAttribute("data-index");
|
||||
if (!indexAttribute) return;
|
||||
const listing = layers[parseInt(indexAttribute, 10)];
|
||||
|
||||
const rect = treeChild.getBoundingClientRect();
|
||||
if (rect.top > clientY || rect.bottom < clientY) {
|
||||
return;
|
||||
}
|
||||
const pointerPercentage = (clientY - rect.top) / rect.height;
|
||||
if (listing.entry.childrenAllowed || listing.childrenPresent) {
|
||||
if (pointerPercentage < 0.25) {
|
||||
insertParentId = listing.parentId;
|
||||
insertDepth = listing.depth - 1;
|
||||
insertIndex = listing.folderIndex;
|
||||
markerHeight = rect.top - layerPanelTop;
|
||||
} else if (pointerPercentage < 0.75 || (listing.childrenPresent && listing.expanded)) {
|
||||
insertParentId = listing.entry.id;
|
||||
insertDepth = listing.depth;
|
||||
insertIndex = 0;
|
||||
highlightFolder = true;
|
||||
highlightFolderIndex = parseInt(indexAttribute, 10);
|
||||
} else {
|
||||
insertParentId = listing.parentId;
|
||||
insertDepth = listing.depth - 1;
|
||||
insertIndex = listing.folderIndex + 1;
|
||||
markerHeight = rect.bottom - layerPanelTop;
|
||||
}
|
||||
} else {
|
||||
if (pointerPercentage < 0.5) {
|
||||
insertParentId = listing.parentId;
|
||||
insertDepth = listing.depth - 1;
|
||||
insertIndex = listing.folderIndex;
|
||||
markerHeight = rect.top - layerPanelTop;
|
||||
} else {
|
||||
insertParentId = listing.parentId;
|
||||
insertDepth = listing.depth - 1;
|
||||
insertIndex = listing.folderIndex + 1;
|
||||
markerHeight = rect.bottom - layerPanelTop;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Dragging to the empty space below all layers
|
||||
let lastLayer = treeChildren[treeChildren.length - 1];
|
||||
if (lastLayer.getBoundingClientRect().bottom < clientY) {
|
||||
const numberRootLayers = layers.filter((listing) => listing.depth === 1).length;
|
||||
insertParentId = undefined;
|
||||
insertDepth = 0;
|
||||
insertIndex = numberRootLayers;
|
||||
markerHeight = lastLayer.getBoundingClientRect().bottom - layerPanelTop;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
select,
|
||||
insertParentId,
|
||||
insertDepth,
|
||||
insertIndex,
|
||||
highlightFolder,
|
||||
highlightFolderIndex,
|
||||
markerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function layerPointerDown(e: PointerEvent, listing: LayerListingInfo) {
|
||||
// Only left click drags
|
||||
if (e.button !== 0 || !draggable) return;
|
||||
|
||||
internalDragState = {
|
||||
active: false,
|
||||
layerId: listing.entry.id,
|
||||
listing: listing,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
function draggingPointerMove(e: PointerEvent) {
|
||||
if (!internalDragState || !list) return;
|
||||
|
||||
// Calculate distance moved
|
||||
if (!internalDragState.active) {
|
||||
const distance = Math.hypot(e.clientX - internalDragState.startX, e.clientY - internalDragState.startY);
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
if (distance > DRAG_THRESHOLD) {
|
||||
internalDragState.active = true;
|
||||
dragInPanel = true;
|
||||
layerToClipUponClick = undefined;
|
||||
|
||||
const layer = internalDragState.listing.entry;
|
||||
if (!$nodeGraph.selected.includes(layer.id)) {
|
||||
fakeHighlightOfNotYetSelectedLayerBeingDragged = layer.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform drag calculations if a drag is occurring
|
||||
if (internalDragState.active) {
|
||||
// Check if the cursor is over any element flagged as a drag drop target
|
||||
// (e.g. a bottom-bar action button whose backend widget has an `on_drag_drop` callback set)
|
||||
const droppable = (e.target instanceof Element && e.target.closest("[data-drag-droppable]")) || undefined;
|
||||
dragDropTarget = droppable instanceof HTMLElement ? droppable : undefined;
|
||||
|
||||
// Hide the move-in-tree insert indicator whenever the cursor enters the bottom bar
|
||||
const overBottomBar = ((e.target instanceof Element && e.target.closest("[data-layer-bottom-bar]")) || undefined) !== undefined;
|
||||
if (dragDropTarget || overBottomBar) {
|
||||
draggingData = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const select = () => {
|
||||
if (internalDragState && !$nodeGraph.selected.includes(internalDragState.layerId)) {
|
||||
selectLayer(internalDragState.listing, false, false);
|
||||
}
|
||||
};
|
||||
|
||||
draggingData = calculateDragIndex(list, e.clientY, select);
|
||||
}
|
||||
}
|
||||
|
||||
function draggingPointerUp(e: PointerEvent) {
|
||||
if (internalDragState?.active && dragDropTarget) {
|
||||
// Ensure the dragged layer is part of the selection, matching the move-in-tree behavior
|
||||
if (!$nodeGraph.selected.includes(internalDragState.layerId)) selectLayer(internalDragState.listing, false, false);
|
||||
|
||||
// Hand off to the button's backend `on_drag_drop` callback via the custom event
|
||||
dragDropTarget.dispatchEvent(new CustomEvent("dragdrop"));
|
||||
|
||||
justFinishedDrag = true;
|
||||
} else if (internalDragState?.active && draggingData) {
|
||||
const { select, insertParentId, insertIndex } = draggingData;
|
||||
|
||||
// Ensure the dragged layer is part of the selection before committing
|
||||
select?.();
|
||||
|
||||
// Holding Alt drops a duplicate of the selection at the target instead of moving the originals
|
||||
if (e.altKey) editor.duplicateLayerInTree(insertParentId, insertIndex);
|
||||
else editor.moveLayerInTree(insertParentId, insertIndex);
|
||||
|
||||
// Prevent the subsequent click event from processing
|
||||
justFinishedDrag = true;
|
||||
} else if (justFinishedDrag) {
|
||||
// Avoid right-click abort getting stuck with `justFinishedDrag` set and blocking the first subsequent click to select a layer
|
||||
setTimeout(() => {
|
||||
justFinishedDrag = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
abortDrag();
|
||||
}
|
||||
|
||||
function abortDrag() {
|
||||
internalDragState = undefined;
|
||||
draggingData = undefined;
|
||||
fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined;
|
||||
dragInPanel = false;
|
||||
dragDropTarget = undefined;
|
||||
}
|
||||
|
||||
function draggingMouseDown(e: MouseEvent) {
|
||||
// Abort if a drag is active and the user presses the right mouse button (button 2)
|
||||
if (e.button === 2 && internalDragState?.active) {
|
||||
justFinishedDrag = true;
|
||||
abortDrag();
|
||||
}
|
||||
}
|
||||
|
||||
function draggingKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && internalDragState?.active) {
|
||||
justFinishedDrag = true;
|
||||
abortDrag();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLayerPanelKeyDown(e: KeyboardEvent) {
|
||||
// TODO: Handle this F2 shortcut detection in the backend, not frontend, so it uses the standard key binding system
|
||||
|
||||
// Only handle F2 if not currently editing a layer name
|
||||
if (e.key === "F2" && !layers.some((layer) => layer.editingName)) {
|
||||
// Find the first selected layer
|
||||
const selectedLayer = layers.find((layer) => layer.entry.selected);
|
||||
if (selectedLayer) {
|
||||
e.preventDefault();
|
||||
onEditLayerName(selectedLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToLayer(currentListing: LayerListingInfo, direction: "Up" | "Down") {
|
||||
// Save the current layer name
|
||||
const inputElement = document.activeElement;
|
||||
if (inputElement instanceof HTMLInputElement) {
|
||||
const name = inputElement.value || "";
|
||||
editor.setLayerName(currentListing.entry.id, name);
|
||||
currentListing.entry.alias = name;
|
||||
}
|
||||
|
||||
// Find current layer index
|
||||
const currentIndex = layers.findIndex((layer) => layer.entry.id === currentListing.entry.id);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// Calculate target index based on direction
|
||||
const targetIndex = direction === "Down" ? currentIndex + 1 : currentIndex - 1;
|
||||
if (targetIndex >= layers.length || targetIndex < 0) return;
|
||||
|
||||
const targetListing = layers[targetIndex];
|
||||
if (!targetListing) return;
|
||||
|
||||
// Exit edit mode on current layer
|
||||
currentListing.editingName = false;
|
||||
draggable = true;
|
||||
layers = layers;
|
||||
|
||||
// Start edit mode on target layer
|
||||
await onEditLayerName(targetListing);
|
||||
}
|
||||
|
||||
function fileDragOver(e: DragEvent) {
|
||||
if (!draggable || !e.dataTransfer || !e.dataTransfer.types.includes("Files")) return;
|
||||
|
||||
// Stop the drag from being shown as cancelled
|
||||
e.preventDefault();
|
||||
dragInPanel = true;
|
||||
|
||||
if (list) draggingData = calculateDragIndex(list, e.clientY);
|
||||
}
|
||||
|
||||
function fileDrop(e: DragEvent) {
|
||||
if (!draggingData || !e.dataTransfer || !e.dataTransfer.types.includes("Files")) return;
|
||||
|
||||
const { insertParentId, insertIndex } = draggingData;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor, undefined, insertParentId, insertIndex));
|
||||
|
||||
draggingData = undefined;
|
||||
fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined;
|
||||
dragInPanel = false;
|
||||
}
|
||||
|
||||
function rebuildLayerHierarchy(layerStructure: LayerStructureEntry[], cache: Map<string, LayerPanelEntry>) {
|
||||
// Track the editing state by flat list index, not layer ID, since a layer can appear at multiple positions
|
||||
const editingIndex = layers.findIndex((layer: LayerListingInfo) => layer.editingName);
|
||||
|
||||
// Clear the layer hierarchy before rebuilding it
|
||||
layers = [];
|
||||
|
||||
// Build the new layer hierarchy
|
||||
const recurse = (children: LayerStructureEntry[], depth: number, parentId: bigint | undefined, parentPath: bigint[], parentsVisible: boolean, parentsUnlocked: boolean) => {
|
||||
children.forEach((item, index) => {
|
||||
const treePath = [...parentPath, item.layerId];
|
||||
const mapping = cache.get(String(item.layerId));
|
||||
|
||||
if (mapping) {
|
||||
mapping.id = item.layerId;
|
||||
layers.push({
|
||||
folderIndex: index,
|
||||
bottomLayer: index === children.length - 1,
|
||||
entry: mapping,
|
||||
editingName: editingIndex === layers.length,
|
||||
depth,
|
||||
parentId,
|
||||
childrenPresent: item.childrenPresent,
|
||||
expanded: item.childrenPresent && item.children.length > 0,
|
||||
ancestorOfSelected: item.descendantSelected,
|
||||
parentsVisible,
|
||||
parentsUnlocked,
|
||||
treePath,
|
||||
});
|
||||
}
|
||||
|
||||
// Call self recursively, propagating this layer's visibility/lock state to its children
|
||||
const childParentsVisible = parentsVisible && (mapping?.visible ?? true);
|
||||
const childParentsUnlocked = parentsUnlocked && (mapping?.unlocked ?? true);
|
||||
if (item.children.length >= 1) recurse(item.children, depth + 1, item.layerId, treePath, childParentsVisible, childParentsUnlocked);
|
||||
});
|
||||
};
|
||||
recurse(layerStructure, 1, undefined, [], true, true);
|
||||
layers = layers;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="layers" on:dragleave={() => (dragInPanel = false)}>
|
||||
<LayoutRow class="control-bar" scrollableX={true}>
|
||||
<WidgetLayout layout={$layersPanelControlBarLeftLayout} layoutTarget="LayersPanelControlLeftBar" />
|
||||
{#if $layersPanelControlBarLeftLayout?.length > 0 && $layersPanelControlBarRightLayout?.length > 0}
|
||||
<Separator />
|
||||
{/if}
|
||||
<WidgetLayout layout={$layersPanelControlBarRightLayout} layoutTarget="LayersPanelControlRightBar" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="list-area" classes={{ "drag-ongoing": Boolean(internalDragState?.active && draggingData) }} scrollableY={true}>
|
||||
<LayoutCol
|
||||
class="list"
|
||||
styles={{ cursor: layerToClipUponClick && layerToClipAltKeyPressed && layerToClipUponClick.entry.clippable ? "alias" : "auto" }}
|
||||
data-layer-panel
|
||||
bind:this={list}
|
||||
on:click={() => deselectAllLayers()}
|
||||
on:dragover={fileDragOver}
|
||||
on:drop={fileDrop}
|
||||
>
|
||||
{#each layers as listing, index}
|
||||
{@const selected = fakeHighlightOfNotYetSelectedLayerBeingDragged !== undefined ? fakeHighlightOfNotYetSelectedLayerBeingDragged === listing.entry.id : listing.entry.selected}
|
||||
<LayoutRow
|
||||
class="layer"
|
||||
classes={{
|
||||
selected,
|
||||
"ancestor-of-selected": listing.ancestorOfSelected,
|
||||
"descendant-of-selected": listing.entry.descendantOfSelected,
|
||||
"selected-but-not-in-selected-network": selected && !listing.entry.inSelectedNetwork,
|
||||
"insert-folder": (draggingData?.highlightFolder || false) && draggingData?.highlightFolderIndex === index,
|
||||
}}
|
||||
styles={{ "--layer-indent-levels": `${listing.depth - 1}` }}
|
||||
data-layer
|
||||
data-index={index}
|
||||
on:pointerdown={(e) => layerPointerDown(e, listing)}
|
||||
on:click={(e) => selectLayerWithModifiers(e, listing)}
|
||||
>
|
||||
{#if listing.entry.childrenAllowed || listing.childrenPresent}
|
||||
<button
|
||||
class="expand-arrow"
|
||||
class:expanded={listing.expanded}
|
||||
disabled={!listing.childrenPresent}
|
||||
data-tooltip-label={listing.expanded ? "Collapse (All)" : "Expand (All)"}
|
||||
data-tooltip-description={(listing.expanded
|
||||
? "Hide this layer's children. (To recursively collapse all descendants, perform the shortcut shown.)"
|
||||
: "Show this layer's children. (To recursively expand all descendants, perform the shortcut shown.)") +
|
||||
(listing.ancestorOfSelected && !listing.expanded ? "\n\nA selected layer is currently contained within.\n" : "")}
|
||||
data-tooltip-shortcut={$tooltip.altClickShortcut?.shortcut ? JSON.stringify($tooltip.altClickShortcut.shortcut) : undefined}
|
||||
on:click={(e) => handleExpandArrowClickWithModifiers(e, listing.treePath)}
|
||||
tabindex="0"
|
||||
></button>
|
||||
{:else}
|
||||
<div class="expand-arrow-none"></div>
|
||||
{/if}
|
||||
{#if listing.entry.clipped}
|
||||
<IconLabel
|
||||
icon="Clipped"
|
||||
class="clipped-arrow"
|
||||
tooltipLabel="Layer Clipped"
|
||||
tooltipDescription="Clipping mask is active. To release it, target the bottom border of the layer and perform the shortcut shown."
|
||||
tooltipShortcut={$tooltip.altClickShortcut}
|
||||
/>
|
||||
{/if}
|
||||
<div class="thumbnail">
|
||||
{#if $nodeGraph.thumbnails.has(listing.entry.id)}
|
||||
{@html $nodeGraph.thumbnails.get(listing.entry.id)}
|
||||
{/if}
|
||||
</div>
|
||||
{#if listing.entry.iconName}
|
||||
<IconLabel icon={listing.entry.iconName} class="layer-type-icon" tooltipLabel="Artboard" />
|
||||
{/if}
|
||||
<LayoutRow class="layer-name" on:dblclick={() => onEditLayerName(listing)}>
|
||||
<input
|
||||
data-text-input
|
||||
type="text"
|
||||
value={listing.entry.alias}
|
||||
placeholder={listing.entry.implementationName}
|
||||
disabled={!listing.editingName}
|
||||
on:blur={() => onEditLayerNameDeselect(listing)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
onEditLayerNameDeselect(listing);
|
||||
} else if (e.key === "Enter") {
|
||||
onEditLayerNameChange(listing, e);
|
||||
} else if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
navigateToLayer(listing, e.shiftKey ? "Up" : "Down");
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
navigateToLayer(listing, "Up");
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
navigateToLayer(listing, "Down");
|
||||
}
|
||||
}}
|
||||
on:change={(e) => onEditLayerNameChange(listing, e)}
|
||||
/>
|
||||
</LayoutRow>
|
||||
{#if !listing.entry.unlocked || !listing.parentsUnlocked}
|
||||
<IconButton
|
||||
class="status-toggle"
|
||||
classes={{ inherited: !listing.parentsUnlocked }}
|
||||
action={(e) => (toggleLayerLock(listing.entry.id), e?.stopPropagation())}
|
||||
size={24}
|
||||
icon={listing.entry.unlocked ? "PadlockUnlocked" : "PadlockLocked"}
|
||||
hoverIcon={listing.entry.unlocked ? "PadlockLocked" : "PadlockUnlocked"}
|
||||
tooltipLabel={listing.entry.unlocked ? "Lock" : "Unlock"}
|
||||
tooltipDescription={!listing.parentsUnlocked ? "A parent of this layer is locked and that status is being inherited." : ""}
|
||||
data-drag-toggle-group="layer-lock"
|
||||
data-drag-toggle-state={listing.entry.unlocked ? "unlocked" : "locked"}
|
||||
/>
|
||||
{:else if activeDragToggleGroup === "layer-lock"}
|
||||
<IconButton
|
||||
class="status-toggle drag-toggle-placeholder"
|
||||
action={(e) => (toggleLayerLock(listing.entry.id), e?.stopPropagation())}
|
||||
size={24}
|
||||
icon="PadlockUnlocked"
|
||||
data-drag-toggle-group="layer-lock"
|
||||
data-drag-toggle-state="unlocked"
|
||||
/>
|
||||
{/if}
|
||||
<IconButton
|
||||
class="status-toggle"
|
||||
classes={{ inherited: !listing.parentsVisible }}
|
||||
action={(e) => (toggleNodeVisibilityLayerPanel(listing.entry.id), e?.stopPropagation())}
|
||||
size={24}
|
||||
icon={listing.entry.visible ? "EyeVisible" : "EyeHidden"}
|
||||
hoverIcon={listing.entry.visible ? "EyeHide" : "EyeShow"}
|
||||
tooltipLabel={listing.entry.visible ? "Hide" : "Show"}
|
||||
tooltipDescription={!listing.parentsVisible ? "A parent of this layer is hidden and that status is being inherited." : ""}
|
||||
data-drag-toggle-group="layer-visibility"
|
||||
data-drag-toggle-state={listing.entry.visible ? "visible" : "hidden"}
|
||||
/>
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{#if draggingData && !draggingData.highlightFolder && dragInPanel}
|
||||
<div class="insert-mark" style:left={`${4 + draggingData.insertDepth * 16}px`} style:top={`${draggingData.markerHeight}px`}></div>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
<LayoutRow class="bottom-bar" classes={{ "layer-drag-active": Boolean(internalDragState?.active) }} scrollableX={true} data-layer-bottom-bar>
|
||||
<WidgetLayout layout={$layersPanelBottomBarLayout} layoutTarget="LayersPanelBottomBar" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.layers {
|
||||
// Control bar
|
||||
.control-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
border-bottom: 1px solid var(--color-2-mildblack);
|
||||
justify-content: space-between;
|
||||
|
||||
.widget-span:first-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom bar
|
||||
.bottom-bar {
|
||||
height: 24px;
|
||||
padding-top: 4px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--color-2-mildblack);
|
||||
|
||||
.widget-span > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.layer-drag-active .icon-button,
|
||||
&.layer-drag-active .popover-button {
|
||||
&.drag-droppable:hover {
|
||||
background: var(--color-e-nearwhite);
|
||||
|
||||
svg {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.drag-droppable) {
|
||||
pointer-events: none;
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer hierarchy
|
||||
.list-area {
|
||||
position: relative;
|
||||
padding-top: 4px;
|
||||
// Combine with the bottom bar to avoid a double border
|
||||
margin-bottom: -1px;
|
||||
|
||||
&.drag-ongoing .layer {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--color-2-mildblack);
|
||||
border-radius: 2px;
|
||||
height: 32px;
|
||||
margin: 0 4px;
|
||||
padding-left: calc(var(--layer-indent-levels) * 16px);
|
||||
|
||||
// Dimming
|
||||
&.selected {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
&.ancestor-of-selected .expand-arrow:not(.expanded) {
|
||||
background-image: var(--inheritance-dots-background-6-lowergray);
|
||||
}
|
||||
|
||||
&.descendant-of-selected {
|
||||
background-image: var(--inheritance-dots-background-4-dimgray);
|
||||
}
|
||||
|
||||
&.selected-but-not-in-selected-network {
|
||||
background: rgba(var(--color-4-dimgray-rgb), 0.5);
|
||||
}
|
||||
|
||||
&.insert-folder::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid var(--color-e-nearwhite);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.expand-arrow {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-right: 4px;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
position: relative;
|
||||
background: none;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--icon-expand-collapse-arrow);
|
||||
}
|
||||
|
||||
&[disabled]::after {
|
||||
background: var(--icon-expand-collapse-arrow-disabled);
|
||||
}
|
||||
|
||||
&:hover:not([disabled]) {
|
||||
background: var(--color-5-dullgray);
|
||||
|
||||
&::after {
|
||||
background: var(--icon-expand-collapse-arrow-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.expand-arrow-none {
|
||||
flex: 0 0 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.clipped-arrow {
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
background-image: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size-mini);
|
||||
background-position: var(--color-transparent-checkered-background-position-mini);
|
||||
background-repeat: var(--color-transparent-checkered-background-repeat);
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-type-icon {
|
||||
margin-left: 8px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1 1 100%;
|
||||
margin: 0 8px;
|
||||
|
||||
input {
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none; // Ok for input element
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
|
||||
&:disabled {
|
||||
user-select: none;
|
||||
// Workaround for `user-select: none` not working on <input> elements
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: var(--color-1-nearblack);
|
||||
padding: 0 4px;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 1;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-toggle {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
&.inherited {
|
||||
background-image: var(--inheritance-stripes-background);
|
||||
}
|
||||
|
||||
// Invisible placeholder rendered only during a lock drag-toggle gesture, so the drag can still land on rows whose lock icon is normally omitted.
|
||||
// Overlaid with absolute positioning so it doesn't shift the layer name's width.
|
||||
&.drag-toggle-placeholder {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
right: 24px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
opacity: 0; // Not `visibility: hidden`, which would exclude it from hit-testing
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
height: 100%;
|
||||
width: calc(24px + 2 * 4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.insert-mark {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
background: var(--color-e-nearwhite);
|
||||
margin-top: 1px;
|
||||
height: 5px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import { propertiesPanelLayout } from "/src/stores/portfolio";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
|
||||
let sectionsCol: LayoutCol | undefined;
|
||||
|
||||
// Interactive dragging to reorder Properties panel node sections (a selected layer's chain nodes, or pinned nodes)
|
||||
type DragState = { nodeId: bigint; startX: number; startY: number; active: boolean };
|
||||
let dragState: DragState | undefined = undefined;
|
||||
let dragging = false;
|
||||
let fromIndex: number | undefined = undefined;
|
||||
let insertIndex: number | undefined = undefined;
|
||||
let insertMarkerTop: number | undefined = undefined;
|
||||
let justFinishedDrag = false; // Used to suppress the click event that follows a drag release (which would otherwise toggle a section)
|
||||
|
||||
onMount(() => {
|
||||
addEventListener("pointermove", draggingPointerMove);
|
||||
addEventListener("pointerup", draggingPointerUp);
|
||||
addEventListener("keydown", draggingKeyDown);
|
||||
addEventListener("mousedown", draggingMouseDown);
|
||||
// Capture phase so this runs before a section header's own click handler
|
||||
addEventListener("click", suppressClickAfterDrag, true);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
removeEventListener("pointermove", draggingPointerMove);
|
||||
removeEventListener("pointerup", draggingPointerUp);
|
||||
removeEventListener("keydown", draggingKeyDown);
|
||||
removeEventListener("mousedown", draggingMouseDown);
|
||||
removeEventListener("click", suppressClickAfterDrag, true);
|
||||
});
|
||||
|
||||
function suppressClickAfterDrag(e: MouseEvent) {
|
||||
if (!justFinishedDrag) return;
|
||||
justFinishedDrag = false;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function reorderableSections(): Element[] {
|
||||
const container = sectionsCol?.div();
|
||||
if (!container) return [];
|
||||
return Array.from(container.querySelectorAll("[data-properties-reorderable-section]"));
|
||||
}
|
||||
|
||||
function sectionPointerDown(e: PointerEvent) {
|
||||
// Only left click drags
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const target = e.target instanceof Element ? e.target : undefined;
|
||||
if (!target) return;
|
||||
|
||||
// The drag handle is the section header
|
||||
const handle = target.closest("[data-properties-reorder-handle]");
|
||||
if (!handle) return;
|
||||
|
||||
// Don't begin a drag when pressing one of the header's own buttons (pin/delete/visibility); only the header itself grabs
|
||||
if (target.closest("button") !== handle) return;
|
||||
|
||||
const section = target.closest("[data-properties-reorderable-section]");
|
||||
const nodeIdAttribute = section?.getAttribute("data-node-id");
|
||||
if (!section || !nodeIdAttribute) return;
|
||||
|
||||
dragState = { nodeId: BigInt(nodeIdAttribute), startX: e.clientX, startY: e.clientY, active: false };
|
||||
}
|
||||
|
||||
function draggingPointerMove(e: PointerEvent) {
|
||||
if (!dragState) return;
|
||||
|
||||
// Wait until the cursor has moved beyond the threshold before treating it as a drag (so a click still toggles the section)
|
||||
if (!dragState.active) {
|
||||
const distance = Math.hypot(e.clientX - dragState.startX, e.clientY - dragState.startY);
|
||||
const DRAG_THRESHOLD = 5;
|
||||
if (distance <= DRAG_THRESHOLD) return;
|
||||
|
||||
dragState.active = true;
|
||||
dragging = true;
|
||||
fromIndex = reorderableSections().findIndex((section) => section.getAttribute("data-node-id") === String(dragState?.nodeId));
|
||||
}
|
||||
|
||||
calculateInsertIndex(e.clientY);
|
||||
}
|
||||
|
||||
function calculateInsertIndex(clientY: number) {
|
||||
const container = sectionsCol?.div();
|
||||
const sections = reorderableSections();
|
||||
if (!container || sections.length === 0) {
|
||||
insertIndex = undefined;
|
||||
insertMarkerTop = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop;
|
||||
// Convert a viewport Y to a position within the (scrollable) container's content, so the marker tracks the content when scrolled
|
||||
const toContentOffset = (viewportY: number) => viewportY - containerRect.top + scrollTop;
|
||||
|
||||
// The insertion index is the number of sections whose vertical midpoint sits above the cursor. The index flips only when
|
||||
// the cursor crosses a section's midpoint, so each gap maps to exactly one index (and gaps between sections are handled).
|
||||
let index = 0;
|
||||
for (let i = 0; i < sections.length; i += 1) {
|
||||
const rect = sections[i].getBoundingClientRect();
|
||||
if (clientY < (rect.top + rect.bottom) / 2) break;
|
||||
index = i + 1;
|
||||
}
|
||||
|
||||
// Position the marker purely from the gap index so it has one fixed spot per gap, rather than snapping between adjacent
|
||||
// sections' edges as the cursor crosses their shared boundary.
|
||||
let markerViewportY;
|
||||
if (index <= 0) {
|
||||
markerViewportY = sections[0].getBoundingClientRect().top - 2;
|
||||
} else if (index >= sections.length) {
|
||||
markerViewportY = sections[sections.length - 1].getBoundingClientRect().bottom + 2;
|
||||
} else {
|
||||
markerViewportY = (sections[index - 1].getBoundingClientRect().bottom + sections[index].getBoundingClientRect().top) / 2;
|
||||
}
|
||||
|
||||
insertIndex = index;
|
||||
insertMarkerTop = toContentOffset(markerViewportY);
|
||||
}
|
||||
|
||||
function draggingPointerUp() {
|
||||
if (dragState?.active) {
|
||||
// Suppress the click that the browser fires after the drag release, so it doesn't toggle the dropped section
|
||||
justFinishedDrag = true;
|
||||
|
||||
// Skip drops that don't actually move the node (into its own slot), or where the dragged section vanished from the DOM mid-drag (fromIndex of -1)
|
||||
if (insertIndex !== undefined && fromIndex !== undefined && fromIndex !== -1 && insertIndex !== fromIndex && insertIndex !== fromIndex + 1) {
|
||||
editor.reorderPropertiesSection(dragState.nodeId, insertIndex);
|
||||
}
|
||||
}
|
||||
|
||||
abortDrag();
|
||||
}
|
||||
|
||||
function draggingKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && dragState?.active) {
|
||||
abortDrag();
|
||||
}
|
||||
}
|
||||
|
||||
function draggingMouseDown(e: MouseEvent) {
|
||||
// Abort an in-progress drag if the user presses the right mouse button
|
||||
if (e.button === 2 && dragState?.active) {
|
||||
abortDrag();
|
||||
}
|
||||
}
|
||||
|
||||
function abortDrag() {
|
||||
dragState = undefined;
|
||||
dragging = false;
|
||||
fromIndex = undefined;
|
||||
insertIndex = undefined;
|
||||
insertMarkerTop = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="properties">
|
||||
<LayoutCol class="sections" classes={{ dragging }} scrollableY={true} bind:this={sectionsCol} on:pointerdown={sectionPointerDown}>
|
||||
<WidgetLayout layout={$propertiesPanelLayout} layoutTarget="PropertiesPanel" />
|
||||
{#if dragging && insertMarkerTop !== undefined}
|
||||
<div class="insert-mark" style:top={`${insertMarkerTop}px`}></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.properties {
|
||||
height: 100%;
|
||||
flex: 1 1 100%;
|
||||
|
||||
.sections {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
|
||||
// While dragging a section, disable pointer events (which inherit down to the sections) so the drop doesn't toggle a section's expansion, and prevent text selection
|
||||
&.dragging {
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Used as a placeholder for empty assist widgets
|
||||
.separator.section.horizontal {
|
||||
margin: 0;
|
||||
margin-left: 24px;
|
||||
|
||||
div {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.insert-mark {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
background: var(--color-e-nearwhite);
|
||||
height: 5px;
|
||||
// The marker's `top` is the center of the gap, so shift up by half its height to straddle that line
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.text-button {
|
||||
flex-basis: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import { welcomeScreenButtonsLayout } from "/src/stores/portfolio";
|
||||
import { pasteFile } from "/src/utility-functions/files";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
|
||||
function dropFile(e: DragEvent) {
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor));
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="welcome-panel" on:dragover={(e) => e.preventDefault()} on:drop={dropFile}>
|
||||
<LayoutCol class="top-spacer"></LayoutCol>
|
||||
<LayoutCol class="content-container">
|
||||
<LayoutCol class="content">
|
||||
<LayoutRow class="logotype">
|
||||
<IconLabel icon="GraphiteLogotypeSolid" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="actions">
|
||||
<WidgetLayout layout={$welcomeScreenButtonsLayout} layoutTarget="WelcomeScreenButtons" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="bottom-message">
|
||||
{#if import.meta.env.MODE === "native"}
|
||||
<TextLabel italic={true} disabled={true}>
|
||||
You are testing Release Candidate 6 of the 1.0 desktop release. Please regularly check Discord for the next testing build and report issues you encounter.
|
||||
</TextLabel>
|
||||
{:else if new Date() < new Date(2026, 10, 1)}
|
||||
<TextLabel italic={true} disabled={true}>
|
||||
May 2026 release — <a href="https://youtube.com/watch?v=U3E-sWo2H_M" target="_blank">What's new? (video)</a>
|
||||
— Note: Some nodes are renamed; Some older documents may render differently and require manual fixes.
|
||||
<a href="https://57130155.graphite.pages.dev/" target="_blank">Need the old version?</a>
|
||||
</TextLabel>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.welcome-panel {
|
||||
background: var(--color-2-mildblack);
|
||||
margin: 4px;
|
||||
border-radius: 2px;
|
||||
justify-content: space-between;
|
||||
|
||||
.content-container {
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
|
||||
.content {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
.logotype {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
svg {
|
||||
width: auto;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 8px;
|
||||
|
||||
table {
|
||||
border-spacing: 8px;
|
||||
margin: -8px;
|
||||
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-spacer {
|
||||
flex: 0 1 48px;
|
||||
}
|
||||
|
||||
.bottom-message {
|
||||
flex: 0 0 48px;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
.text-label {
|
||||
white-space: wrap;
|
||||
margin: 0 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import WidgetSection from "/src/components/widgets/WidgetSection.svelte";
|
||||
import WidgetSpan from "/src/components/widgets/WidgetSpan.svelte";
|
||||
import WidgetTable from "/src/components/widgets/WidgetTable.svelte";
|
||||
import type { Layout, LayoutTarget } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export let layout: Layout;
|
||||
export let layoutTarget: LayoutTarget;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
</script>
|
||||
|
||||
{#each layout as layoutGroup}
|
||||
{#if "Row" in layoutGroup}
|
||||
<WidgetSpan direction="row" widgets={layoutGroup.Row.rowWidgets} {layoutTarget} class={className} {classes} />
|
||||
{:else if "Column" in layoutGroup}
|
||||
<WidgetSpan direction="column" widgets={layoutGroup.Column.columnWidgets} {layoutTarget} class={className} {classes} />
|
||||
{:else if "Section" in layoutGroup}
|
||||
<WidgetSection widgetData={layoutGroup.Section} {layoutTarget} class={className} {classes} />
|
||||
{:else if "Table" in layoutGroup}
|
||||
<WidgetTable widgetData={layoutGroup.Table} {layoutTarget} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import WidgetSpan from "/src/components/widgets/WidgetSpan.svelte";
|
||||
import type { EditorWrapper, LayoutTarget, WidgetSection as WidgetSectionData } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export let widgetData: WidgetSectionData;
|
||||
export let layoutTarget: LayoutTarget;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
// Whether the section is expanded is owned by the backend (persisted per node), so just reflect it here
|
||||
$: expanded = widgetData.expanded;
|
||||
|
||||
// A reorderable section is a Properties panel node section the user can drag to reorder (a layer chain's node, or a pinned node)
|
||||
$: reorderable = layoutTarget === "PropertiesPanel" && widgetData.draggable;
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
</script>
|
||||
|
||||
<!-- TODO: Implement collapsable sections with properties system -->
|
||||
<LayoutCol class={`widget-section ${className}`.trim()} {classes} data-properties-reorderable-section={reorderable ? "" : undefined} data-node-id={reorderable ? String(widgetData.id) : undefined}>
|
||||
<button
|
||||
class="header"
|
||||
class:expanded
|
||||
data-properties-reorder-handle={reorderable ? "" : undefined}
|
||||
on:click|stopPropagation={() => editor.toggleNodePropertiesSectionExpanded(widgetData.id)}
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="expand-arrow"></div>
|
||||
<TextLabel tooltipLabel={widgetData.name} tooltipDescription={widgetData.description} bold={true}>{widgetData.name}</TextLabel>
|
||||
<IconButton
|
||||
icon={widgetData.pinned ? "PinActive" : "PinInactive"}
|
||||
tooltipDescription={widgetData.pinned ? "Unpin this node so it's no longer shown here when nothing is selected." : "Pin this node so it's shown here when nothing is selected."}
|
||||
size={24}
|
||||
action={(e) => {
|
||||
editor.setNodePinned(widgetData.id, !widgetData.pinned);
|
||||
e?.stopPropagation();
|
||||
}}
|
||||
class="show-only-on-hover"
|
||||
/>
|
||||
<IconButton
|
||||
icon="Trash"
|
||||
tooltipDescription="Delete this node from the layer chain."
|
||||
size={24}
|
||||
action={(e) => {
|
||||
editor.deleteNode(widgetData.id);
|
||||
e?.stopPropagation();
|
||||
}}
|
||||
class="show-only-on-hover"
|
||||
/>
|
||||
<IconButton
|
||||
icon={widgetData.visible ? "EyeVisible" : "EyeHidden"}
|
||||
hoverIcon={widgetData.visible ? "EyeHide" : "EyeShow"}
|
||||
tooltipDescription={widgetData.visible ? "Hide this node." : "Show this node."}
|
||||
size={24}
|
||||
action={(e) => {
|
||||
editor.toggleNodeVisibilityLayerPanel(widgetData.id);
|
||||
e?.stopPropagation();
|
||||
}}
|
||||
class={widgetData.visible ? "show-only-on-hover" : ""}
|
||||
/>
|
||||
</button>
|
||||
{#if expanded}
|
||||
<LayoutCol class="body" data-block-hover-transfer>
|
||||
{#each widgetData.layout as layoutGroup}
|
||||
{#if "Row" in layoutGroup}
|
||||
<WidgetSpan direction="row" widgets={layoutGroup.Row.rowWidgets} {layoutTarget} />
|
||||
{:else if "Section" in layoutGroup}
|
||||
<svelte:self widgetData={layoutGroup.Section} {layoutTarget} />
|
||||
{/if}
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.widget-section {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
|
||||
+ .widget-section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 24px;
|
||||
padding-left: 8px;
|
||||
padding-right: 0;
|
||||
margin-bottom: 4px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--color-2-mildblack);
|
||||
|
||||
&.expanded {
|
||||
border-radius: 4px 4px 0 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
.expand-arrow::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-4-dimgray);
|
||||
|
||||
.expand-arrow::after {
|
||||
background: var(--icon-expand-collapse-arrow-hover);
|
||||
}
|
||||
|
||||
+ .body {
|
||||
border: 1px solid var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
|
||||
.expand-arrow {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--icon-expand-collapse-arrow);
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
height: 18px;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover) .header .show-only-on-hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 0 7px;
|
||||
padding-top: 1px;
|
||||
margin-top: -1px;
|
||||
background: var(--color-3-darkgray);
|
||||
border: 1px solid var(--color-2-mildblack);
|
||||
border-radius: 0 0 4px 4px;
|
||||
overflow: hidden;
|
||||
|
||||
> .widget-span.row {
|
||||
&:first-child {
|
||||
margin-top: calc(4px - 1px);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: calc(4px - 1px);
|
||||
}
|
||||
|
||||
> .text-button:first-child {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
> .text-label:first-of-type {
|
||||
flex: 0 0 25%;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
> .parameter-expose-button + .text-label:first-of-type {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
> .text-button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
> .radio-input button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
> .parameter-expose-button + .text-label ~ .number-input:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import NodeCatalog from "/src/components/floating-menus/NodeCatalog.svelte";
|
||||
import BreadcrumbTrailButtons from "/src/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import ImageButton from "/src/components/widgets/buttons/ImageButton.svelte";
|
||||
import ParameterExposeButton from "/src/components/widgets/buttons/ParameterExposeButton.svelte";
|
||||
import PopoverButton from "/src/components/widgets/buttons/PopoverButton.svelte";
|
||||
import TextButton from "/src/components/widgets/buttons/TextButton.svelte";
|
||||
import CheckboxInput from "/src/components/widgets/inputs/CheckboxInput.svelte";
|
||||
import ColorComparisonInput from "/src/components/widgets/inputs/ColorComparisonInput.svelte";
|
||||
import ColorInput from "/src/components/widgets/inputs/ColorInput.svelte";
|
||||
import ColorPresetsInput from "/src/components/widgets/inputs/ColorPresetsInput.svelte";
|
||||
import DropdownInput from "/src/components/widgets/inputs/DropdownInput.svelte";
|
||||
import NumberInput from "/src/components/widgets/inputs/NumberInput.svelte";
|
||||
import RadioInput from "/src/components/widgets/inputs/RadioInput.svelte";
|
||||
import ReferencePointInput from "/src/components/widgets/inputs/ReferencePointInput.svelte";
|
||||
import SpectrumInput from "/src/components/widgets/inputs/SpectrumInput.svelte";
|
||||
import TextAreaInput from "/src/components/widgets/inputs/TextAreaInput.svelte";
|
||||
import TextInput from "/src/components/widgets/inputs/TextInput.svelte";
|
||||
import VisualColorPickersInput from "/src/components/widgets/inputs/VisualColorPickersInput.svelte";
|
||||
import WorkingColorsInput from "/src/components/widgets/inputs/WorkingColorsInput.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import ImageLabel from "/src/components/widgets/labels/ImageLabel.svelte";
|
||||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { ColorPickerStore } from "/src/stores/color-picker";
|
||||
import { parseFillChoiceUI } from "/src/utility-functions/colors";
|
||||
import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Extract the discriminant key names from the Widget tagged enum union (e.g. "TextButton" | "CheckboxInput" | ...)
|
||||
type WidgetKind = Widget extends infer T ? (T extends Record<infer K, unknown> ? K & string : never) : never;
|
||||
// Extract the props type for a specific widget kind (e.g. WidgetProps<"TextButton"> gives the Wasm-generated TextButton interface)
|
||||
type WidgetProps<K extends WidgetKind> = Extract<Widget, Record<K, unknown>>[K];
|
||||
// A Widget tagged enum unwrapped into a correlated [kind, props] tuple
|
||||
type UnwrappedWidget = { [K in WidgetKind]: [kind: K, props: WidgetProps<K>] }[WidgetKind];
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const colorPickerStore = getContext<ColorPickerStore>("colorPicker");
|
||||
|
||||
export let widgets: WidgetInstance[];
|
||||
export let direction: "row" | "column";
|
||||
export let layoutTarget: LayoutTarget;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
export let narrow = false;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
|
||||
function widgetValueCommit(widgetIndex: number, value: unknown) {
|
||||
editor.widgetValueCommit(layoutTarget, widgets[widgetIndex].widgetId, value);
|
||||
}
|
||||
|
||||
function widgetValueUpdate(widgetIndex: number, value: unknown, resendWidget: boolean) {
|
||||
editor.widgetValueUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget);
|
||||
}
|
||||
|
||||
function widgetValueCommitAndUpdate(widgetIndex: number, value: unknown, resendWidget: boolean) {
|
||||
editor.widgetValueCommitAndUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget);
|
||||
}
|
||||
|
||||
function widgetValueDragDrop(widgetIndex: number) {
|
||||
editor.widgetValueDragDrop(layoutTarget, widgets[widgetIndex].widgetId);
|
||||
}
|
||||
|
||||
// Extracts the kind and props from a Widget tagged enum, validated against the widget registry.
|
||||
// The overload declares the precise correlated return type while the implementation uses broader types.
|
||||
function unwrapWidget(widgetInstance: WidgetInstance): UnwrappedWidget | undefined;
|
||||
function unwrapWidget(widgetInstance: WidgetInstance) {
|
||||
const entry = Object.entries(widgetInstance.widget)[0];
|
||||
if (!entry || !(entry[0] in widgetResolvers)) return undefined;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Resolves the unwrapped widget through the registry to get its Svelte component and computed props.
|
||||
function resolveWidget([kind, widgetProps]: UnwrappedWidget, widgetIndex: number) {
|
||||
const config = widgetResolvers[kind];
|
||||
return {
|
||||
component: config.component,
|
||||
props: config.getProps(widgetProps, widgetIndex),
|
||||
slot: config.getSlotContent?.(widgetProps),
|
||||
};
|
||||
}
|
||||
|
||||
// Svelte has no variance-safe base type for component constructors
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SvelteComponentAny = any;
|
||||
|
||||
type WidgetConfig<K extends WidgetKind> = {
|
||||
component: SvelteComponentAny;
|
||||
getProps(props: WidgetProps<K>, widgetIndex: number): Record<string, unknown> | undefined;
|
||||
getSlotContent?(props: WidgetProps<K>): string;
|
||||
};
|
||||
|
||||
// The union of all individual widget props types (distributed across each WidgetKind member)
|
||||
type AnyWidgetProps = { [K in WidgetKind]: WidgetProps<K> }[WidgetKind];
|
||||
|
||||
// Uniform view for runtime lookup — widens the per-kind config types to a single type that
|
||||
// accepts any widget props, avoiding the correlated unions problem at the call site
|
||||
type WidgetResolver = {
|
||||
component: SvelteComponentAny;
|
||||
getProps(props: AnyWidgetProps, widgetIndex: number): Record<string, unknown> | undefined;
|
||||
getSlotContent?(props: AnyWidgetProps): string;
|
||||
};
|
||||
|
||||
// Overload: callers provide the precise mapped type (preserving per-entry type inference).
|
||||
// Implementation: receives/returns the widened uniform type (no cast needed).
|
||||
// Method syntax bivariance makes WidgetConfig<K> assignable to WidgetResolver in the overload check.
|
||||
function createWidgetResolvers(registry: { [K in WidgetKind]: WidgetConfig<K> }): Record<WidgetKind, WidgetResolver>;
|
||||
function createWidgetResolvers(registry: Record<WidgetKind, WidgetResolver>): Record<WidgetKind, WidgetResolver> {
|
||||
return registry;
|
||||
}
|
||||
|
||||
const widgetResolvers = createWidgetResolvers({
|
||||
CheckboxInput: {
|
||||
component: CheckboxInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: { checked: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
|
||||
}),
|
||||
},
|
||||
ColorComparisonInput: {
|
||||
component: ColorComparisonInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
swap: () => widgetValueCommitAndUpdate(index, undefined, true),
|
||||
},
|
||||
}),
|
||||
},
|
||||
ColorInput: {
|
||||
component: ColorInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
value: parseFillChoiceUI(props.value),
|
||||
$$events: {
|
||||
value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||
startHistoryTransaction: () => widgetValueCommit(index, props.value),
|
||||
},
|
||||
}),
|
||||
},
|
||||
ColorPresetsInput: {
|
||||
component: ColorPresetsInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
// The widget dispatches `"None"` or a bare `SRGBA8`, wrap the color in `{ Solid: ... }` so the payload matches Rust's `FillChoiceUI` shape (which the `Preset` variant expects).
|
||||
preset: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { Preset: e.detail === "None" ? "None" : { Solid: e.detail } }, true),
|
||||
eyedropperColorCode: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { EyedropperColorCode: e.detail }, true),
|
||||
},
|
||||
}),
|
||||
},
|
||||
DropdownInput: {
|
||||
component: DropdownInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
hoverInEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||
hoverOutEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||
selectedIndex: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true),
|
||||
},
|
||||
}),
|
||||
},
|
||||
ParameterExposeButton: {
|
||||
component: ParameterExposeButton,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
action: () => widgetValueCommitAndUpdate(index, undefined, true),
|
||||
}),
|
||||
},
|
||||
IconButton: {
|
||||
component: IconButton,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
action: () => widgetValueCommitAndUpdate(index, undefined, true),
|
||||
actionDragDrop: () => widgetValueDragDrop(index),
|
||||
}),
|
||||
},
|
||||
IconLabel: {
|
||||
component: IconLabel,
|
||||
getProps: (props) => ({ ...props }),
|
||||
},
|
||||
ShortcutLabel: {
|
||||
component: ShortcutLabel,
|
||||
getProps: (props) => {
|
||||
if (!props.shortcut) return undefined;
|
||||
return { ...props };
|
||||
},
|
||||
},
|
||||
ImageLabel: {
|
||||
component: ImageLabel,
|
||||
getProps: (props) => ({ ...props }),
|
||||
},
|
||||
ImageButton: {
|
||||
component: ImageButton,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
action: () => widgetValueCommitAndUpdate(index, undefined, true),
|
||||
}),
|
||||
},
|
||||
NodeCatalog: {
|
||||
component: NodeCatalog,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: { selectNodeType: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false) },
|
||||
}),
|
||||
},
|
||||
NumberInput: {
|
||||
component: NumberInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
incrementCallbackIncrease: () => widgetValueCommitAndUpdate(index, { increment: "Increase" }, false),
|
||||
incrementCallbackDecrease: () => widgetValueCommitAndUpdate(index, { increment: "Decrease" }, false),
|
||||
$$events: {
|
||||
value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, true),
|
||||
commitText: (e: CustomEvent) => widgetValueUpdate(index, e.detail, true),
|
||||
startHistoryTransaction: () => widgetValueCommit(index, props.value),
|
||||
commitHistoryTransaction: () => editor.endTransaction(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
ReferencePointInput: {
|
||||
component: ReferencePointInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: { value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
|
||||
}),
|
||||
},
|
||||
SpectrumInput: {
|
||||
component: SpectrumInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||
dragging: (e: CustomEvent<boolean>) => colorPickerStore.setDragging(e.detail),
|
||||
},
|
||||
}),
|
||||
},
|
||||
VisualColorPickersInput: {
|
||||
component: VisualColorPickersInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||
startHistoryTransaction: () => widgetValueCommit(index, undefined),
|
||||
commitHistoryTransaction: () => widgetValueCommit(index, undefined),
|
||||
dragStateChange: (e: CustomEvent<boolean>) => colorPickerStore.setDragging(e.detail),
|
||||
},
|
||||
}),
|
||||
},
|
||||
PopoverButton: {
|
||||
component: PopoverButton,
|
||||
getProps: (props) => ({
|
||||
...props,
|
||||
layoutTarget,
|
||||
}),
|
||||
},
|
||||
RadioInput: {
|
||||
component: RadioInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: { selectedIndex: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
|
||||
}),
|
||||
},
|
||||
Separator: {
|
||||
component: Separator,
|
||||
getProps: (props) => ({ ...props }),
|
||||
},
|
||||
WorkingColorsInput: {
|
||||
component: WorkingColorsInput,
|
||||
getProps: (props) => ({ ...props }),
|
||||
},
|
||||
TextAreaInput: {
|
||||
component: TextAreaInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: { commitText: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false) },
|
||||
}),
|
||||
},
|
||||
TextButton: {
|
||||
component: TextButton,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
action: () => widgetValueCommitAndUpdate(index, [], true),
|
||||
$$events: { selectedEntryValuePath: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false) },
|
||||
}),
|
||||
},
|
||||
BreadcrumbTrailButtons: {
|
||||
component: BreadcrumbTrailButtons,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
action: (breadcrumbIndex: number) => widgetValueCommitAndUpdate(index, breadcrumbIndex, true),
|
||||
}),
|
||||
},
|
||||
TextInput: {
|
||||
component: TextInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: { commitText: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
|
||||
}),
|
||||
},
|
||||
TextLabel: {
|
||||
component: TextLabel,
|
||||
getProps: ({ value: _, ...rest }) => rest,
|
||||
getSlotContent: (props) => props.value,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={`widget-span ${className} ${extraClasses}`.trim()} class:narrow class:row={direction === "row"} class:column={direction === "column"}>
|
||||
{#each widgets as widget, widgetIndex}
|
||||
{@const unwrapped = unwrapWidget(widget)}
|
||||
{#if unwrapped}
|
||||
{@const { component, props, slot } = resolveWidget(unwrapped, widgetIndex)}
|
||||
{#if props !== undefined && slot !== undefined}
|
||||
<svelte:component this={component} {...props}>{slot}</svelte:component>
|
||||
{:else if props !== undefined}
|
||||
<svelte:component this={component} {...props} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.widget-span.column {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-span.row {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
min-height: var(--row-height);
|
||||
--row-height: 32px;
|
||||
|
||||
&.narrow {
|
||||
--row-height: 24px;
|
||||
}
|
||||
|
||||
> * {
|
||||
--widget-height: 24px;
|
||||
// Vertically center the widget within the row
|
||||
margin: calc((var(--row-height) - var(--widget-height)) / 2) 0;
|
||||
min-height: var(--widget-height);
|
||||
|
||||
&:not(.multiline) {
|
||||
line-height: var(--widget-height);
|
||||
}
|
||||
|
||||
&.icon-label.size-12 {
|
||||
--widget-height: 12px;
|
||||
}
|
||||
|
||||
&.icon-label.size-16 {
|
||||
--widget-height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import WidgetSpan from "/src/components/widgets/WidgetSpan.svelte";
|
||||
import type { LayoutTarget, WidgetTable } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export let widgetData: WidgetTable;
|
||||
export let layoutTarget: LayoutTarget;
|
||||
|
||||
$: columns = widgetData.tableWidgets.length > 0 ? widgetData.tableWidgets[0].length : 0;
|
||||
</script>
|
||||
|
||||
<table class:unstyled={widgetData.unstyled}>
|
||||
<tbody>
|
||||
{#each widgetData.tableWidgets as row}
|
||||
<tr>
|
||||
{#each row as widget}
|
||||
<td colspan={row.length < columns ? columns - row.length + 1 : undefined}>
|
||||
<WidgetSpan direction="row" widgets={[widget]} {layoutTarget} narrow={true} />
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style lang="scss">
|
||||
table:not(.unstyled) {
|
||||
background: var(--color-3-darkgray);
|
||||
border: none;
|
||||
border-spacing: 4px;
|
||||
border-radius: 2px;
|
||||
|
||||
td {
|
||||
background: var(--color-2-mildblack);
|
||||
vertical-align: top;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
tr:first-child td {
|
||||
background-image: var(--inheritance-dots-background-4-dimgray);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import TextButton from "/src/components/widgets/buttons/TextButton.svelte";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Content
|
||||
export let labels: string[];
|
||||
export let disabled = false;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (index: number) => void;
|
||||
|
||||
function truncate(label: string): string {
|
||||
let maxLength = 40;
|
||||
|
||||
if (label.length <= maxLength) return label;
|
||||
|
||||
let truncated = label;
|
||||
const hasQuotes = label.startsWith(`"`) && label.endsWith(`"`);
|
||||
|
||||
if (hasQuotes) {
|
||||
truncated = label.slice(1, -1);
|
||||
maxLength -= 2;
|
||||
}
|
||||
|
||||
truncated = truncated.slice(0, maxLength - 1) + "…";
|
||||
|
||||
if (hasQuotes) truncated = `"${truncated}"`;
|
||||
|
||||
return truncated;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="breadcrumb-trail-buttons" {tooltipLabel} {tooltipDescription} {tooltipShortcut}>
|
||||
{#each labels as label, index}
|
||||
<TextButton label={truncate(label)} emphasized={index === labels.length - 1} {disabled} action={() => !disabled && index !== labels.length - 1 && action(index)} />
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.breadcrumb-trail-buttons {
|
||||
.text-button {
|
||||
position: relative;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
background: var(--button-background-color);
|
||||
clip-path: polygon(8px -1px, 0 -1px, 4px 12px, 0 25px, 8px 25px);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
background: var(--button-background-color);
|
||||
clip-path: polygon(0 -1px, 4px -1px, 8px 12px, 4px 25px, 0 25px);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
// Make this non-functional button not change color on hover
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import type { IconName, IconSize } from "/src/icons";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Content
|
||||
export let icon: IconName;
|
||||
export let hoverIcon: IconName | undefined = undefined;
|
||||
export let size: IconSize;
|
||||
export let disabled = false;
|
||||
// Styling
|
||||
export let emphasized = false;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (e?: MouseEvent) => void;
|
||||
// Fired when a draggable item is dropped onto this button. Setting this also flags the button as a valid drop target
|
||||
// (the consumer of the drag interaction looks for `[data-drag-droppable]` and dispatches a `dragdrop` event on it).
|
||||
export let actionDragDrop: (() => void) | undefined = undefined;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
|
||||
// Element-level listener for the `dragdrop` custom event that consumers dispatch when something is dropped on this button
|
||||
let buttonElement: HTMLButtonElement | undefined;
|
||||
function handleDragDrop() {
|
||||
actionDragDrop?.();
|
||||
}
|
||||
onMount(() => buttonElement?.addEventListener("dragdrop", handleDragDrop));
|
||||
onDestroy(() => buttonElement?.removeEventListener("dragdrop", handleDragDrop));
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={`icon-button size-${size} ${className} ${extraClasses}`.trim()}
|
||||
class:hover-icon={hoverIcon && !disabled}
|
||||
class:disabled
|
||||
class:emphasized
|
||||
class:drag-droppable={Boolean(actionDragDrop)}
|
||||
bind:this={buttonElement}
|
||||
on:click={action}
|
||||
{disabled}
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
data-drag-droppable={actionDragDrop ? "" : undefined}
|
||||
tabindex={emphasized ? -1 : 0}
|
||||
{...$$restProps}
|
||||
>
|
||||
<IconLabel {icon} />
|
||||
{#if hoverIcon && !disabled}
|
||||
<IconLabel icon={hoverIcon} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.icon-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
// The `where` pseudo-class does not contribtue to specificity
|
||||
& + :where(.icon-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-5-dullgray);
|
||||
}
|
||||
|
||||
&.hover-icon {
|
||||
&:not(:hover) .icon-label:nth-of-type(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover .icon-label:nth-of-type(1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
&.emphasized {
|
||||
background: var(--color-e-nearwhite);
|
||||
|
||||
svg {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
&.size-12 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.size-16 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.size-24 {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&.size-32 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { IMAGE_BASE64_STRINGS } from "/src/utility-functions/images";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
// Content
|
||||
export let image: string;
|
||||
export let width: string | undefined;
|
||||
export let height: string | undefined;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (e?: MouseEvent) => void;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<img
|
||||
src={IMAGE_BASE64_STRINGS[image]}
|
||||
style:width
|
||||
style:height
|
||||
class={`image-button ${className} ${extraClasses}`.trim()}
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
alt=""
|
||||
on:click={action}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.image-button {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 2px;
|
||||
|
||||
+ .image-button.image-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import type { FrontendGraphDataType, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Content
|
||||
export let exposed: boolean;
|
||||
export let dataType: FrontendGraphDataType;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (e?: MouseEvent) => void;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="parameter-expose-button">
|
||||
<button
|
||||
class:exposed
|
||||
style:--data-type-color={`var(--color-data-${dataType.toLowerCase()})`}
|
||||
style:--data-type-color-dim={`var(--color-data-${dataType.toLowerCase()}-dim)`}
|
||||
on:click={action}
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if !exposed}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8">
|
||||
<circle class="interior" r="3" cx="4" cy="4" />
|
||||
<path
|
||||
class="outline"
|
||||
d="M4,1C5.656,1 7,2.344 7,4C7,5.656 5.656,7 4,7C2.344,7 1,5.656 1,4C1,2.344 2.344,1 4,1ZM4,2C2.896,2 2,2.896 2,4C2,5.104 2.896,6 4,6C5.104,6 6,5.104 6,4C6,2.896 5.104,2 4,2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8">
|
||||
<path class="interior" d="M0,6.306L0,1.694C0,0.228 1.06,-0.41 2.356,0.276L7.028,2.752C8.324,3.438 8.324,4.562 7.028,5.248L2.356,7.723C1.06,8.41 0,7.771 0,6.306z" />
|
||||
<path
|
||||
class="outline"
|
||||
d="M1.364,8C0.56,8 0,7.372 0,6.306L0,1.694C0,0.228 1.06,-0.41 2.356,0.276L7.028,2.752C8.324,3.439 8.324,4.561 7.028,5.248L2.356,7.724C2.002,7.911 1.666,8 1.364,8ZM1.364,7.2C1.542,7.2 1.756,7.137 1.981,7.017L6.653,4.541C7.031,4.341 7.2,4.125 7.2,4C7.2,3.875 7.031,3.659 6.653,3.459L1.982,0.983C1.756,0.863 1.542,0.8 1.364,0.8C0.873,0.8 0.8,1.36 0.8,1.694L0.8,6.306C0.8,6.64 0.873,7.2 1.364,7.2z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.parameter-expose-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
max-height: 24px;
|
||||
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
fill: none;
|
||||
stroke: none;
|
||||
|
||||
.outline {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.interior {
|
||||
fill: var(--data-type-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.outline {
|
||||
fill: var(--data-type-color);
|
||||
}
|
||||
|
||||
.interior {
|
||||
fill: var(--data-type-color-dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import FloatingMenu from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { IconName, PopoverButtonStyle } from "/src/icons";
|
||||
import type { MenuDirection, ActionShortcut, Layout, LayoutTarget } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export let layoutTarget: LayoutTarget;
|
||||
|
||||
// Content
|
||||
export let style: PopoverButtonStyle = "DropdownArrow";
|
||||
export let icon: IconName | undefined = undefined;
|
||||
export let disabled = false;
|
||||
// Children
|
||||
export let popoverLayout: Layout;
|
||||
export let popoverMinWidth = 1;
|
||||
export let menuDirection: MenuDirection = "Bottom";
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (() => void) | undefined = undefined;
|
||||
|
||||
let open = false;
|
||||
|
||||
function onClick() {
|
||||
open = true;
|
||||
action?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="popover-button" classes={{ "has-icon": icon !== undefined, "direction-top": menuDirection === "Top" }}>
|
||||
<IconButton
|
||||
class="dropdown-icon"
|
||||
classes={{ open }}
|
||||
{disabled}
|
||||
action={() => onClick()}
|
||||
icon={style || "DropdownArrow"}
|
||||
size={16}
|
||||
{tooltipLabel}
|
||||
{tooltipDescription}
|
||||
{tooltipShortcut}
|
||||
data-floating-menu-spawner
|
||||
/>
|
||||
{#if icon !== undefined}
|
||||
<IconLabel class="descriptive-icon" classes={{ open }} {disabled} {icon} {tooltipLabel} {tooltipDescription} {tooltipShortcut} />
|
||||
{/if}
|
||||
|
||||
<FloatingMenu {open} on:open={({ detail }) => (open = detail)} minWidth={popoverMinWidth} type="Popover" direction={menuDirection || "Bottom"}>
|
||||
<WidgetLayout layout={popoverLayout} {layoutTarget} />
|
||||
</FloatingMenu>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.popover-button {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.has-icon {
|
||||
width: 36px;
|
||||
|
||||
.dropdown-icon {
|
||||
padding-left: calc(36px - 16px);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
&.direction-top .dropdown-icon .icon-label {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
&:hover:not(.disabled),
|
||||
&.open:not(.disabled) {
|
||||
background: var(--color-5-dullgray);
|
||||
}
|
||||
}
|
||||
|
||||
.descriptive-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: auto 0;
|
||||
margin-left: calc(-16px - 16px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-menu {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
|
||||
.floating-menu-content > :first-child:not(:has(:not(.text-label))),
|
||||
.floating-menu-content > :first-child:not(:has(:not(.checkbox-input))) {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.floating-menu-content > :last-child:not(:has(:not(.text-label))),
|
||||
.floating-menu-content > :last-child:not(:has(:not(.checkbox-input))) {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
&.direction-top .floating-menu {
|
||||
bottom: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import MenuList from "/src/components/floating-menus/MenuList.svelte";
|
||||
import ConditionalWrapper from "/src/components/layout/ConditionalWrapper.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { IconName } from "/src/icons";
|
||||
import type { MenuListEntry, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ selectedEntryValuePath: string[] }>();
|
||||
|
||||
let self: MenuList;
|
||||
let open = false;
|
||||
|
||||
// Note: IconButton should instead be used if only an icon, but no label, is desired.
|
||||
// However, if multiple TextButton widgets are used in a group with only some having no label, this component is able to accommodate that.
|
||||
|
||||
// Content
|
||||
export let label: string;
|
||||
export let icon: IconName | undefined = undefined;
|
||||
export let hoverIcon: IconName | undefined = undefined;
|
||||
export let disabled = false;
|
||||
// Children
|
||||
export let menuListChildren: MenuListEntry[][] | undefined = undefined;
|
||||
export let menuListChildrenHash: bigint | undefined = undefined;
|
||||
// Styling
|
||||
export let emphasized = false;
|
||||
export let flush = false;
|
||||
export let narrow = false;
|
||||
// Sizing
|
||||
export let minWidth = 0;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
// Callbacks
|
||||
// TODO: Replace this with an event binding (and on other components that do this)
|
||||
export let action: (() => void) | undefined;
|
||||
|
||||
$: menuListChildrenExists = (menuListChildren?.length ?? 0) > 0;
|
||||
|
||||
// Handles either a button click or, if applicable, the opening of the menu list floating menu
|
||||
function onClick(e: MouseEvent) {
|
||||
// If there's no menu to open, trigger the action
|
||||
if ((menuListChildren?.length ?? 0) === 0) {
|
||||
// Call the action
|
||||
if (!disabled) action?.();
|
||||
|
||||
// Exit early so we don't continue on and try to open the menu
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the target so that keyboard inputs are sent to the dropdown
|
||||
if (e.target instanceof HTMLElement) e.target.focus();
|
||||
|
||||
// Open the menu list floating menu
|
||||
if (self) self.open = true;
|
||||
else throw new Error("The menu bar floating menu has no reference to `self`");
|
||||
}
|
||||
</script>
|
||||
|
||||
<ConditionalWrapper condition={menuListChildrenExists} wrapperClass="text-button-container">
|
||||
<button
|
||||
class="text-button"
|
||||
class:open={self?.open}
|
||||
class:hover-icon={hoverIcon && !disabled}
|
||||
class:emphasized
|
||||
class:disabled
|
||||
class:narrow
|
||||
class:flush
|
||||
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
data-emphasized={emphasized || undefined}
|
||||
data-disabled={disabled || undefined}
|
||||
data-text-button
|
||||
tabindex={disabled ? -1 : 0}
|
||||
data-floating-menu-spawner
|
||||
data-block-hover-transfer={menuListChildrenExists ? undefined : ""}
|
||||
on:click={onClick}
|
||||
>
|
||||
{#if icon}
|
||||
<IconLabel {icon} />
|
||||
{#if hoverIcon && !disabled}
|
||||
<IconLabel icon={hoverIcon} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if label}
|
||||
<TextLabel>{label}</TextLabel>
|
||||
{/if}
|
||||
</button>
|
||||
{#if menuListChildrenExists}
|
||||
<MenuList
|
||||
on:selectedEntryValuePath={({ detail }) => dispatch("selectedEntryValuePath", detail)}
|
||||
on:open={({ detail }) => (open = detail)}
|
||||
{open}
|
||||
entries={menuListChildren || []}
|
||||
entriesHash={menuListChildrenHash || 0n}
|
||||
direction="Bottom"
|
||||
minWidth={240}
|
||||
drawIcon={true}
|
||||
bind:this={self}
|
||||
/>
|
||||
{/if}
|
||||
</ConditionalWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
.text-button-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
height: var(--widget-height);
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--button-background-color);
|
||||
color: var(--button-text-color);
|
||||
--button-background-color: var(--color-4-dimgray);
|
||||
--button-text-color: var(--color-e-nearwhite);
|
||||
--widget-height: 24px;
|
||||
|
||||
&.narrow.narrow {
|
||||
--widget-height: 20px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
--button-background-color: var(--color-6-lowergray);
|
||||
--button-text-color: var(--color-f-white);
|
||||
}
|
||||
|
||||
&.hover-icon {
|
||||
&:not(:hover) .icon-label:nth-of-type(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover .icon-label:nth-of-type(1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--button-background-color: var(--color-4-dimgray);
|
||||
--button-text-color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.emphasized {
|
||||
--button-background-color: var(--color-e-nearwhite);
|
||||
--button-text-color: var(--color-2-mildblack);
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
--button-background-color: var(--color-f-white);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--button-background-color: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
&.flush {
|
||||
--button-background-color: none;
|
||||
--button-text-color: var(--color-e-nearwhite);
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
--button-background-color: var(--color-5-dullgray);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--button-text-color: var(--color-8-uppergray);
|
||||
--button-background-color: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
fill: var(--button-text-color);
|
||||
|
||||
+ .text-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom styling for when multiple TextButton widgets are used next to one another in a row or column
|
||||
.widget-span.row > .text-button + .text-button,
|
||||
.layout-row > .text-button + .text-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.widget-span.column > .text-button + .text-button,
|
||||
.layout-column > .text-button + .text-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import type { IconName } from "/src/icons";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ checked: boolean }>();
|
||||
const backupId = String(Math.random()).substring(2);
|
||||
|
||||
// Content
|
||||
export let checked = false;
|
||||
export let icon: IconName | undefined = undefined;
|
||||
export let forLabel: bigint | undefined = undefined;
|
||||
export let disabled = false;
|
||||
export let mixed = false;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
|
||||
let inputElement: HTMLInputElement | undefined;
|
||||
|
||||
$: id = forLabel !== undefined ? String(forLabel) : backupId;
|
||||
$: displayIcon = !checked && (!icon || icon === "Checkmark") ? "Empty12px" : icon || "Checkmark";
|
||||
$: if (inputElement) inputElement.indeterminate = mixed;
|
||||
|
||||
export function isChecked() {
|
||||
return checked;
|
||||
}
|
||||
|
||||
export function input(): HTMLInputElement | undefined {
|
||||
return inputElement;
|
||||
}
|
||||
|
||||
function toggleCheckboxFromLabel(e: KeyboardEvent) {
|
||||
const target = e.target instanceof HTMLLabelElement ? e.target : undefined;
|
||||
const previousSibling = target?.previousSibling instanceof HTMLInputElement ? target.previousSibling : undefined;
|
||||
previousSibling?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="checkbox-input">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`checkbox-input-${id}`}
|
||||
bind:checked
|
||||
on:change={(_) => {
|
||||
// Clicking a mixed-state checkbox always transitions to ticked rather than following HTML's default toggle from the previous `checked` value
|
||||
if (mixed && inputElement && !inputElement.checked) {
|
||||
inputElement.checked = true;
|
||||
checked = true;
|
||||
}
|
||||
dispatch("checked", inputElement?.checked || false);
|
||||
}}
|
||||
{disabled}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
bind:this={inputElement}
|
||||
/>
|
||||
<label
|
||||
class:disabled
|
||||
class:checked
|
||||
class:mixed
|
||||
for={`checkbox-input-${id}`}
|
||||
on:keydown={(e) => e.key === "Enter" && toggleCheckboxFromLabel(e)}
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
>
|
||||
<LayoutRow class="checkbox-box">
|
||||
<IconLabel icon={displayIcon} />
|
||||
</LayoutRow>
|
||||
</label>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.checkbox-input {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
// We can't use `display: none` because it must be visible to work as a tabbale input that accepts a space bar actuation
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
// Firefox weirdly applies a 2px border which causes this element to take up a 4x4 square of space, so this removes it from the flow to prevent it from offsetting the label
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
// Unchecked
|
||||
label {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
// Provides rounded corners for the :focus outline
|
||||
border-radius: 2px;
|
||||
|
||||
.checkbox-box {
|
||||
flex: 0 0 auto;
|
||||
background: var(--color-5-dullgray);
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
// Hovered while unchecked
|
||||
&:hover .checkbox-box,
|
||||
&.label-is-hovered .checkbox-box {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
// Disabled while unchecked
|
||||
&.disabled .checkbox-box {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
|
||||
// Checked
|
||||
input:checked + label {
|
||||
.checkbox-box {
|
||||
background: var(--color-e-nearwhite);
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
// Hovered while checked
|
||||
&:hover .checkbox-box,
|
||||
&.label-is-hovered .checkbox-box {
|
||||
background: var(--color-f-white);
|
||||
}
|
||||
|
||||
// Disabled while checked
|
||||
&.disabled .checkbox-box {
|
||||
background: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed (takes priority over both checked and unchecked appearances)
|
||||
label.mixed .checkbox-box,
|
||||
input:checked + label.mixed .checkbox-box {
|
||||
position: relative;
|
||||
background: var(--color-5-dullgray);
|
||||
|
||||
.icon-label {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-8-uppergray);
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
+ .text-label.text-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ swap: undefined }>();
|
||||
|
||||
export let newColor: SRGBA8 | undefined;
|
||||
export let oldColor: SRGBA8 | undefined;
|
||||
export let newColorCSS: string;
|
||||
export let newColorContrasting: string;
|
||||
export let oldColorCSS: string;
|
||||
export let oldColorContrasting: string;
|
||||
export let isNone: boolean;
|
||||
export let oldIsNone: boolean;
|
||||
export let disabled = false;
|
||||
export let differs: boolean;
|
||||
export let outlineAmount: number;
|
||||
|
||||
$: outlined = outlineAmount > 0.0001;
|
||||
$: transparency = (newColor?.alpha ?? 255) < 255 || (oldColor?.alpha ?? 255) < 255;
|
||||
</script>
|
||||
|
||||
<LayoutRow
|
||||
class="color-comparison-input"
|
||||
classes={{ outlined, transparency, disabled }}
|
||||
styles={{
|
||||
"--outline-amount": outlineAmount,
|
||||
"--new-color": newColorCSS || undefined,
|
||||
"--new-color-contrasting": newColorContrasting,
|
||||
"--old-color": oldColorCSS || undefined,
|
||||
"--old-color-contrasting": oldColorContrasting,
|
||||
}}
|
||||
tooltipDescription={differs ? "Comparison between the present color choice (left) and the color before it was changed (right)." : "The present color choice."}
|
||||
>
|
||||
{#if differs && !disabled}
|
||||
<div class="swap-button-background"></div>
|
||||
<IconButton class="swap-button" icon="SwapHorizontal" size={16} action={() => dispatch("swap")} tooltipLabel="Swap" />
|
||||
{/if}
|
||||
<LayoutCol class="new-color" classes={{ none: isNone }}>
|
||||
{#if differs}
|
||||
<TextLabel>New</TextLabel>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
{#if differs}
|
||||
<LayoutCol class="old-color" classes={{ none: oldIsNone }}>
|
||||
<TextLabel>Old</TextLabel>
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.color-comparison-input {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&.outlined::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--color-0-black-rgb), var(--outline-amount));
|
||||
}
|
||||
|
||||
&.transparency {
|
||||
background-image: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-repeat: var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
.swap-button-background {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.25;
|
||||
border-radius: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
background: var(--new-color-contrasting);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
background: var(--old-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.swap-button {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.new-color {
|
||||
background: var(--new-color);
|
||||
|
||||
.text-label {
|
||||
text-align: left;
|
||||
margin: 2px 8px;
|
||||
color: var(--new-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.old-color {
|
||||
background: var(--old-color);
|
||||
|
||||
.text-label {
|
||||
text-align: right;
|
||||
margin: 2px 8px;
|
||||
color: var(--old-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.new-color,
|
||||
.old-color {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
|
||||
&.none {
|
||||
background: var(--color-none);
|
||||
background-repeat: var(--color-none-repeat);
|
||||
background-position: var(--color-none-position);
|
||||
background-size: var(--color-none-size-32px);
|
||||
background-image: var(--color-none-image-32px);
|
||||
|
||||
.text-label {
|
||||
// Many stacked white shadows helps to increase the opacity and approximate shadow spread which does not exist for text shadows
|
||||
text-shadow:
|
||||
0 0 4px white,
|
||||
0 0 4px white,
|
||||
0 0 4px white,
|
||||
0 0 4px white,
|
||||
0 0 4px white,
|
||||
0 0 4px white,
|
||||
0 0 4px white,
|
||||
0 0 4px white,
|
||||
0 0 4px white,
|
||||
0 0 4px white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
transition: opacity 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import { contrastingOutlineFactor, fillChoiceUIColor, fillChoiceUIGradient } from "/src/utility-functions/colors";
|
||||
import type { FillChoiceUI, MenuDirection, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ value: FillChoiceUI; startHistoryTransaction: undefined }>();
|
||||
|
||||
// Content
|
||||
export let value: FillChoiceUI;
|
||||
export let chosenGradient: string | undefined = undefined;
|
||||
export let allowNone = false;
|
||||
// export let allowTransparency = false; // TODO: Implement
|
||||
export let menuDirection: MenuDirection = "Bottom";
|
||||
export let disabled = false;
|
||||
export let mixed = false;
|
||||
// Styling
|
||||
export let narrow = false;
|
||||
// Sizing
|
||||
export let minWidth = 0;
|
||||
export let maxWidth = 0;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
|
||||
let open = false;
|
||||
|
||||
$: outlineFactor = contrastingOutlineFactor(value, "--color-3-darkgray", 0.01);
|
||||
$: outlined = outlineFactor > 0.0001;
|
||||
$: gradient = fillChoiceUIGradient(value);
|
||||
$: solidColor = fillChoiceUIColor(value);
|
||||
$: none = value === "None";
|
||||
$: transparency = gradient ? gradient.color.some((color) => color.alpha < 255) : solidColor ? solidColor.alpha < 255 : false;
|
||||
</script>
|
||||
|
||||
<LayoutCol
|
||||
class="color-input"
|
||||
classes={{ open, disabled, narrow, none, transparency, outlined, mixed, "direction-top": menuDirection === "Top" }}
|
||||
styles={{
|
||||
...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}),
|
||||
...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}),
|
||||
}}
|
||||
{tooltipLabel}
|
||||
{tooltipDescription}
|
||||
{tooltipShortcut}
|
||||
>
|
||||
<button style:--chosen-gradient={chosenGradient} style:--outline-amount={outlineFactor} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner></button>
|
||||
<ColorPicker
|
||||
{open}
|
||||
{disabled}
|
||||
colorOrGradient={value}
|
||||
direction={menuDirection || "Bottom"}
|
||||
on:open={({ detail }) => (open = detail)}
|
||||
on:colorOrGradient={({ detail }) => {
|
||||
value = detail;
|
||||
dispatch("value", detail);
|
||||
}}
|
||||
on:startHistoryTransaction={() => {
|
||||
// This event is sent to the backend so it knows to start a transaction for the history system. See discussion for some explanation:
|
||||
// <https://github.com/GraphiteEditor/Graphite/pull/1584#discussion_r1477592483>
|
||||
dispatch("startHistoryTransaction");
|
||||
}}
|
||||
{allowNone}
|
||||
/>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.color-input {
|
||||
position: relative;
|
||||
min-width: 80px;
|
||||
|
||||
&.narrow.narrow {
|
||||
--widget-height: 20px;
|
||||
}
|
||||
|
||||
> button {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--chosen-gradient);
|
||||
}
|
||||
|
||||
.text-label {
|
||||
background: var(--color-5-dullgray);
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
height: 12px;
|
||||
border-radius: 0 0 0 2px;
|
||||
padding-right: 4px;
|
||||
padding-left: 4px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.outlined > button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--color-5-dullgray-rgb), var(--outline-amount));
|
||||
}
|
||||
|
||||
&.transparency > button {
|
||||
background-image: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-repeat: var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
&.none > button {
|
||||
background: var(--color-none);
|
||||
background-repeat: var(--color-none-repeat);
|
||||
background-position: var(--color-none-position);
|
||||
background-size: var(--color-none-size-24px);
|
||||
background-image: var(--color-none-image-24px);
|
||||
}
|
||||
|
||||
&.disabled.none > button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-4-dimgray);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.mixed > button {
|
||||
position: relative;
|
||||
background: var(--color-e-nearwhite);
|
||||
background-image: none;
|
||||
|
||||
&::before {
|
||||
background: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.disabled):hover > button .text-label,
|
||||
&:not(.disabled).open > button .text-label {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
|
||||
&.disabled > button .text-label {
|
||||
background: var(--color-4-dimgray);
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
> .floating-menu {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.direction-top > .floating-menu {
|
||||
bottom: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||
import { createSRgba8 } from "/src/utility-functions/colors";
|
||||
import type { SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
type PresetColor = "Black" | "White" | "Red" | "Yellow" | "Green" | "Cyan" | "Blue" | "Magenta";
|
||||
|
||||
const PURE_COLORS: Record<PresetColor, [number, number, number]> = {
|
||||
Black: [0, 0, 0],
|
||||
White: [255, 255, 255],
|
||||
Red: [255, 0, 0],
|
||||
Yellow: [255, 255, 0],
|
||||
Green: [0, 255, 0],
|
||||
Cyan: [0, 255, 255],
|
||||
Blue: [0, 0, 255],
|
||||
Magenta: [255, 0, 255],
|
||||
};
|
||||
const PURE_COLORS_GRAYABLE: [PresetColor, string, string][] = [
|
||||
["Red", "#ff0000", "#4c4c4c"],
|
||||
["Yellow", "#ffff00", "#e3e3e3"],
|
||||
["Green", "#00ff00", "#969696"],
|
||||
["Cyan", "#00ffff", "#b2b2b2"],
|
||||
["Blue", "#0000ff", "#1c1c1c"],
|
||||
["Magenta", "#ff00ff", "#696969"],
|
||||
];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
preset: SRGBA8 | "None";
|
||||
eyedropperColorCode: string;
|
||||
}>();
|
||||
|
||||
export let disabled = false;
|
||||
export let showNoneOption = false;
|
||||
|
||||
function pickPreset(preset: PresetColor | "None") {
|
||||
if (disabled) return;
|
||||
dispatch("preset", preset === "None" ? "None" : createSRgba8(...PURE_COLORS[preset], 255));
|
||||
}
|
||||
|
||||
// TODO: Replace this temporary usage of the browser eyedropper API, that only works in Chromium-based browsers, with the custom color sampler system used by the Eyedropper tool
|
||||
function eyedropperSupported(): boolean {
|
||||
// TODO: Implement support in the desktop app for OS-level color picking
|
||||
if (import.meta.env.MODE === "native") return false;
|
||||
|
||||
return window.EyeDropper !== undefined;
|
||||
}
|
||||
|
||||
async function activateEyedropperSample() {
|
||||
if (!eyedropperSupported()) return;
|
||||
|
||||
try {
|
||||
const result = await new EyeDropper().open();
|
||||
dispatch("eyedropperColorCode", result.sRGBHex);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="color-presets-input" classes={{ disabled }}>
|
||||
{#if showNoneOption}
|
||||
<button
|
||||
class="preset-color none"
|
||||
{disabled}
|
||||
on:click={() => pickPreset("None")}
|
||||
data-tooltip-label="Set to No Color"
|
||||
data-tooltip-description={disabled ? "Disabled (read-only)." : ""}
|
||||
tabindex="0"
|
||||
></button>
|
||||
<Separator style="Related" />
|
||||
{/if}
|
||||
<button class="preset-color black" {disabled} on:click={() => pickPreset("Black")} data-tooltip-label="Set to Black" data-tooltip-description={disabled ? "Disabled (read-only)." : ""} tabindex="0"
|
||||
></button>
|
||||
<Separator style="Related" />
|
||||
<button class="preset-color white" {disabled} on:click={() => pickPreset("White")} data-tooltip-label="Set to White" data-tooltip-description={disabled ? "Disabled (read-only)." : ""} tabindex="0"
|
||||
></button>
|
||||
<Separator style="Related" />
|
||||
<button class="preset-color pure" {disabled} tabindex="-1">
|
||||
{#each PURE_COLORS_GRAYABLE as [preset, color, gray]}
|
||||
<div
|
||||
on:click={() => pickPreset(preset)}
|
||||
style:--pure-color={color}
|
||||
style:--pure-color-gray={gray}
|
||||
data-tooltip-label={`Set to ${preset}`}
|
||||
data-tooltip-description={disabled ? "Disabled (read-only)." : ""}
|
||||
></div>
|
||||
{/each}
|
||||
</button>
|
||||
{#if eyedropperSupported()}
|
||||
<Separator style="Related" />
|
||||
<IconButton icon="Eyedropper" size={24} {disabled} action={activateEyedropperSample} tooltipLabel="Eyedropper" tooltipDescription="Sample a pixel color from the document." />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.color-presets-input {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
|
||||
.preset-color {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
height: 24px;
|
||||
flex: 1 1 100%;
|
||||
|
||||
&.none {
|
||||
background: var(--color-none);
|
||||
background-repeat: var(--color-none-repeat);
|
||||
background-position: var(--color-none-position);
|
||||
background-size: var(--color-none-size-24px);
|
||||
background-image: var(--color-none-image-24px);
|
||||
|
||||
&,
|
||||
& ~ .black,
|
||||
& ~ .white {
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&.black {
|
||||
background: black;
|
||||
}
|
||||
|
||||
&.white {
|
||||
background: white;
|
||||
}
|
||||
|
||||
&.pure {
|
||||
width: 24px;
|
||||
font-size: 0;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
width: calc(100% / 3);
|
||||
height: 50%;
|
||||
// For the least jarring luminance conversion, these colors are derived by placing a black layer with the "desaturate" blend mode over the colors.
|
||||
// We don't use the CSS `filter: grayscale(1);` property because it produces overly dark tones for bright colors with a noticeable jump on hover.
|
||||
background: var(--pure-color-gray);
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
&:hover div {
|
||||
background: var(--pure-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.preset-color {
|
||||
transition: opacity 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-color.pure:hover div {
|
||||
background: var(--pure-color-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import MenuList from "/src/components/floating-menus/MenuList.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { MenuListEntry, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const DASH_ENTRY: MenuListEntry = {
|
||||
value: "",
|
||||
label: "-",
|
||||
icon: undefined,
|
||||
disabled: false,
|
||||
children: [],
|
||||
childrenHash: 0n,
|
||||
font: undefined,
|
||||
tooltipLabel: "",
|
||||
tooltipDescription: "",
|
||||
tooltipShortcut: undefined,
|
||||
};
|
||||
|
||||
const dispatch = createEventDispatcher<{ selectedIndex: number; hoverInEntry: number; hoverOutEntry: number }>();
|
||||
|
||||
let self: LayoutRow | undefined;
|
||||
|
||||
// Content
|
||||
export let selectedIndex: number | undefined = undefined; // When not provided, a dash is displayed
|
||||
export let drawIcon = false;
|
||||
export let disabled = false;
|
||||
// Children
|
||||
export let entries: MenuListEntry[][];
|
||||
export let entriesHash: bigint | undefined = undefined;
|
||||
// Styling
|
||||
export let narrow = false;
|
||||
// Behavior
|
||||
export let virtualScrolling = false;
|
||||
export let interactive = true;
|
||||
// Sizing
|
||||
export let minWidth = 0;
|
||||
export let maxWidth = 0;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
|
||||
let activeEntry = makeActiveEntry();
|
||||
let activeEntrySkipWatcher = false;
|
||||
let initialSelectedIndex: number | undefined = undefined;
|
||||
let open = false;
|
||||
|
||||
$: watchSelectedIndex(selectedIndex);
|
||||
$: watchEntries(entries);
|
||||
$: watchActiveEntry(activeEntry);
|
||||
$: watchOpen(open);
|
||||
|
||||
function watchOpen(open: boolean) {
|
||||
initialSelectedIndex = open ? selectedIndex : undefined;
|
||||
}
|
||||
|
||||
// Called only when `selectedIndex` is changed from outside this component
|
||||
function watchSelectedIndex(_: typeof selectedIndex) {
|
||||
activeEntrySkipWatcher = true;
|
||||
activeEntry = makeActiveEntry();
|
||||
}
|
||||
|
||||
// Called only when `entries` is changed from outside this component
|
||||
function watchEntries(_: typeof entries) {
|
||||
activeEntrySkipWatcher = true;
|
||||
activeEntry = makeActiveEntry();
|
||||
}
|
||||
|
||||
// Called when the `activeEntry` two-way binding on this component's MenuList component is changed, or by the `watchSelectedIndex()` watcher above (but we want to skip that case)
|
||||
function watchActiveEntry(activeEntry: MenuListEntry) {
|
||||
if (activeEntrySkipWatcher) {
|
||||
activeEntrySkipWatcher = false;
|
||||
} else if (activeEntry !== DASH_ENTRY) {
|
||||
// We need to set to the initial value first to track a right history step, as if we hover in initial selection.
|
||||
if (initialSelectedIndex !== undefined) dispatch("hoverInEntry", initialSelectedIndex);
|
||||
const index = entries.flat().findIndex((entry) => entry.value === activeEntry.value);
|
||||
if (index !== -1) {
|
||||
dispatch("selectedIndex", index);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Selected index not found in entries:", activeEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchHoverInEntry(hoveredEntry: MenuListEntry) {
|
||||
const index = entries.flat().findIndex((entry) => entry.value === hoveredEntry.value);
|
||||
|
||||
if (index !== -1) {
|
||||
dispatch("hoverInEntry", index);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Hovered entry not found in entries:", hoveredEntry);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchHoverOutEntry() {
|
||||
if (initialSelectedIndex !== undefined) dispatch("hoverOutEntry", initialSelectedIndex);
|
||||
}
|
||||
|
||||
function makeActiveEntry(): MenuListEntry {
|
||||
const allEntries = entries.flat();
|
||||
|
||||
if (selectedIndex !== undefined && selectedIndex >= 0 && selectedIndex < allEntries.length) {
|
||||
return allEntries[selectedIndex];
|
||||
}
|
||||
return DASH_ENTRY;
|
||||
}
|
||||
|
||||
function unFocusDropdownBox(e: FocusEvent) {
|
||||
const blurTarget = (e.target instanceof Element ? e.target.closest("[data-dropdown-input]") : undefined) || undefined;
|
||||
if (blurTarget !== self?.div?.()) open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow
|
||||
class="dropdown-input"
|
||||
classes={{ narrow }}
|
||||
styles={{
|
||||
...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}),
|
||||
...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}),
|
||||
}}
|
||||
bind:this={self}
|
||||
data-dropdown-input
|
||||
>
|
||||
<LayoutRow
|
||||
class="dropdown-box"
|
||||
classes={{ disabled, open }}
|
||||
{tooltipLabel}
|
||||
{tooltipDescription}
|
||||
{tooltipShortcut}
|
||||
on:click={() => !disabled && (open = true)}
|
||||
on:blur={unFocusDropdownBox}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
data-floating-menu-spawner
|
||||
>
|
||||
{#if activeEntry.icon}
|
||||
<IconLabel class="dropdown-icon" icon={activeEntry.icon} />
|
||||
{/if}
|
||||
<TextLabel class="dropdown-label">{activeEntry.label}</TextLabel>
|
||||
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
||||
</LayoutRow>
|
||||
<MenuList
|
||||
on:naturalWidth={({ detail }) => (minWidth = detail)}
|
||||
on:activeEntry={({ detail }) => (activeEntry = detail)}
|
||||
on:hoverInEntry={({ detail }) => dispatchHoverInEntry(detail)}
|
||||
on:hoverOutEntry={() => dispatchHoverOutEntry()}
|
||||
on:open={({ detail }) => (open = detail)}
|
||||
{open}
|
||||
{activeEntry}
|
||||
{entries}
|
||||
entriesHash={entriesHash || 0n}
|
||||
{drawIcon}
|
||||
{interactive}
|
||||
{virtualScrolling}
|
||||
direction="Bottom"
|
||||
scrollableY={true}
|
||||
/>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.dropdown-input {
|
||||
position: relative;
|
||||
--widget-height: 24px;
|
||||
|
||||
&.narrow.narrow {
|
||||
--widget-height: 20px;
|
||||
}
|
||||
|
||||
.dropdown-box {
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
border-radius: 2px;
|
||||
background: var(--color-1-nearblack);
|
||||
height: var(--widget-height);
|
||||
|
||||
.dropdown-label {
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin: 4px 8px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
& + .dropdown-label {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
margin: 4px;
|
||||
margin-right: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-2-mildblack);
|
||||
|
||||
.text-label {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list .floating-menu-container .floating-menu-content {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import { operatingSystem } from "/src/utility-functions/platform";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
value: string;
|
||||
textFocused: undefined;
|
||||
textChanged: undefined;
|
||||
textChangeCanceled: undefined;
|
||||
}>();
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let value: string;
|
||||
export let label: string | undefined = undefined;
|
||||
export let spellcheck = false;
|
||||
export let disabled = false;
|
||||
export let monospace = false;
|
||||
export let narrow = false;
|
||||
export let textarea = false;
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
export let hideContextMenu = false;
|
||||
|
||||
let inputOrTextarea: HTMLInputElement | HTMLTextAreaElement | undefined;
|
||||
let id = String(Math.random()).substring(2);
|
||||
let macKeyboardLayout = operatingSystem() === "Mac";
|
||||
|
||||
$: dispatch("value", value);
|
||||
|
||||
// Select (highlight) all the text. For technical reasons, it is necessary to pass the current text.
|
||||
export function selectAllText(currentText: string) {
|
||||
if (!inputOrTextarea) return;
|
||||
|
||||
// Setting the value directly is required to make the following `select()` call work
|
||||
inputOrTextarea.value = currentText;
|
||||
inputOrTextarea.select();
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
inputOrTextarea?.focus();
|
||||
}
|
||||
|
||||
export function unFocus() {
|
||||
inputOrTextarea?.blur();
|
||||
}
|
||||
|
||||
export function getValue(): string {
|
||||
return inputOrTextarea?.value || "";
|
||||
}
|
||||
|
||||
export function setInputElementValue(value: string) {
|
||||
if (!inputOrTextarea) return;
|
||||
|
||||
inputOrTextarea.value = value;
|
||||
}
|
||||
|
||||
export function element(): HTMLInputElement | HTMLTextAreaElement | undefined {
|
||||
return inputOrTextarea;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
dispatch("textChangeCanceled");
|
||||
|
||||
if (inputOrTextarea) preventEscapeClosingParentFloatingMenu(inputOrTextarea);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
|
||||
<LayoutRow class={`field-input ${className}`} classes={{ disabled, narrow, monospace, ...classes }} style={styleName} {styles} {tooltipLabel} {tooltipDescription} {tooltipShortcut}>
|
||||
{#if !textarea}
|
||||
<input
|
||||
type="text"
|
||||
class:has-label={label}
|
||||
id={`field-input-${id}`}
|
||||
{spellcheck}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
bind:this={inputOrTextarea}
|
||||
bind:value
|
||||
on:focus={() => dispatch("textFocused")}
|
||||
on:blur={() => dispatch("textChanged")}
|
||||
on:change={() => dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Enter" && dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Escape" && cancel()}
|
||||
on:pointerdown
|
||||
on:contextmenu={(e) => hideContextMenu && e.preventDefault()}
|
||||
data-input-element
|
||||
/>
|
||||
{:else}
|
||||
<textarea
|
||||
class:has-label={label}
|
||||
id={`field-input-${id}`}
|
||||
class="scrollable-y"
|
||||
data-scrollable-y
|
||||
{spellcheck}
|
||||
{disabled}
|
||||
bind:this={inputOrTextarea}
|
||||
bind:value
|
||||
on:focus={() => dispatch("textFocused")}
|
||||
on:blur={() => dispatch("textChanged")}
|
||||
on:change={() => dispatch("textChanged")}
|
||||
on:keydown={(e) => (macKeyboardLayout ? e.metaKey : e.ctrlKey) && e.key === "Enter" && dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Escape" && cancel()}
|
||||
on:pointerdown
|
||||
on:contextmenu={(e) => hideContextMenu && e.preventDefault()}
|
||||
></textarea>
|
||||
{/if}
|
||||
{#if label}
|
||||
<label for={`field-input-${id}`} on:pointerdown>{label}</label>
|
||||
{/if}
|
||||
<slot />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.field-input {
|
||||
min-width: 80px;
|
||||
height: auto;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
background: var(--color-1-nearblack);
|
||||
flex-direction: row-reverse;
|
||||
|
||||
&.narrow.narrow {
|
||||
--widget-height: 20px;
|
||||
}
|
||||
|
||||
&.monospace {
|
||||
input,
|
||||
textarea {
|
||||
font-family: "Source Code Pro", monospace;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
flex: 0 0 auto;
|
||||
line-height: calc(var(--widget-height) - 6px);
|
||||
padding: 3px 0;
|
||||
padding-right: 4px;
|
||||
margin-left: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:not(.disabled) label {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
flex: 1 1 100%;
|
||||
width: 0;
|
||||
min-width: 30px;
|
||||
height: calc(var(--widget-height) - 6px);
|
||||
line-height: calc(var(--widget-height) - 6px);
|
||||
margin: 0 8px;
|
||||
padding: 3px 0;
|
||||
outline: none; // Ok for input/textarea element
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-e-nearwhite);
|
||||
caret-color: var(--color-e-nearwhite);
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
input {
|
||||
&:not(:focus).has-label {
|
||||
text-align: right;
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
text-align: left;
|
||||
|
||||
& + label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: calc((var(--widget-height) - 6px) * 3);
|
||||
margin: 3px;
|
||||
padding: 0 5px;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-2-mildblack);
|
||||
|
||||
label,
|
||||
input,
|
||||
textarea {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { RadioEntryData } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
|
||||
|
||||
// Content
|
||||
export let selectedIndex: number | undefined = undefined;
|
||||
export let disabled = false;
|
||||
// Children
|
||||
export let entries: RadioEntryData[];
|
||||
// Styling
|
||||
export let narrow = false;
|
||||
// Sizing
|
||||
export let minWidth = 0;
|
||||
|
||||
$: mixed = selectedIndex === undefined && !disabled;
|
||||
|
||||
function handleEntryClick(radioEntryData: RadioEntryData) {
|
||||
const index = entries.indexOf(radioEntryData);
|
||||
dispatch("selectedIndex", index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="radio-input" classes={{ disabled, narrow, mixed }} styles={{ ...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}) }}>
|
||||
{#each entries as entry, index}
|
||||
<button
|
||||
class:active={!mixed ? index === selectedIndex : undefined}
|
||||
on:click={() => handleEntryClick(entry)}
|
||||
data-tooltip-label={entry.tooltipLabel}
|
||||
data-tooltip-description={entry.tooltipDescription}
|
||||
data-tooltip-shortcut={entry.tooltipShortcut?.shortcut ? JSON.stringify(entry.tooltipShortcut.shortcut) : undefined}
|
||||
tabindex={index === selectedIndex ? -1 : 0}
|
||||
{disabled}
|
||||
>
|
||||
{#if entry.icon}
|
||||
<IconLabel icon={entry.icon} />
|
||||
{/if}
|
||||
{#if entry.label}
|
||||
<TextLabel>{entry.label}</TextLabel>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.radio-input {
|
||||
background: var(--color-4-dimgray);
|
||||
border-radius: 2px;
|
||||
--widget-height: 24px;
|
||||
height: var(--widget-height);
|
||||
|
||||
button {
|
||||
background: var(--color-4-dimgray);
|
||||
fill: var(--color-e-nearwhite);
|
||||
border-radius: 2px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
margin: 2px 1px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// `min-width: fit-content` and `flex: 1 1 0` together allow us to occupy space such that we're always at least the content width,
|
||||
// but if the container is set wider, we distribute the space evenly (so buttons with short and long labels would have equal widths).
|
||||
min-width: fit-content;
|
||||
flex: 1 1 0;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-e-nearwhite);
|
||||
color: var(--color-2-mildblack);
|
||||
|
||||
svg {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
margin: 2px;
|
||||
|
||||
+ .text-label {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
margin: 0 8px;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.disabled) button:not(.active):hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled button {
|
||||
color: var(--color-8-uppergray);
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-8-uppergray);
|
||||
color: var(--color-2-mildblack);
|
||||
|
||||
svg {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.narrow.narrow {
|
||||
--widget-height: 20px;
|
||||
height: var(--widget-height);
|
||||
|
||||
button {
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.mixed {
|
||||
button:not(:hover),
|
||||
&.disabled button:hover {
|
||||
background: var(--color-5-dullgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { ReferencePoint, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ value: ReferencePoint }>();
|
||||
|
||||
// Content
|
||||
export let value: string;
|
||||
export let disabled = false;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
|
||||
function setValue(newValue: ReferencePoint) {
|
||||
dispatch("value", newValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="reference-point-input"
|
||||
class:disabled
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
>
|
||||
<button on:click={() => setValue("TopLeft")} class="row-1 col-1" class:active={value === "TopLeft"} tabindex="-1" {disabled}><div></div></button>
|
||||
<button on:click={() => setValue("TopCenter")} class="row-1 col-2" class:active={value === "TopCenter"} tabindex="-1" {disabled}><div></div></button>
|
||||
<button on:click={() => setValue("TopRight")} class="row-1 col-3" class:active={value === "TopRight"} tabindex="-1" {disabled}><div></div></button>
|
||||
<button on:click={() => setValue("CenterLeft")} class="row-2 col-1" class:active={value === "CenterLeft"} tabindex="-1" {disabled}><div></div></button>
|
||||
<button on:click={() => setValue("Center")} class="row-2 col-2" class:active={value === "Center"} tabindex="-1" {disabled}><div></div></button>
|
||||
<button on:click={() => setValue("CenterRight")} class="row-2 col-3" class:active={value === "CenterRight"} tabindex="-1" {disabled}><div></div></button>
|
||||
<button on:click={() => setValue("BottomLeft")} class="row-3 col-1" class:active={value === "BottomLeft"} tabindex="-1" {disabled}><div></div></button>
|
||||
<button on:click={() => setValue("BottomCenter")} class="row-3 col-2" class:active={value === "BottomCenter"} tabindex="-1" {disabled}><div></div></button>
|
||||
<button on:click={() => setValue("BottomRight")} class="row-3 col-3" class:active={value === "BottomRight"} tabindex="-1" {disabled}><div></div></button>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.reference-point-input {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
--reference-point-border-color: var(--color-5-dullgray);
|
||||
--reference-point-fill-active: var(--color-e-nearwhite);
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-1-nearblack);
|
||||
border: 1px solid var(--reference-point-border-color);
|
||||
|
||||
&.active {
|
||||
border-color: transparent;
|
||||
background: var(--reference-point-fill-active);
|
||||
}
|
||||
|
||||
&.col-1::before,
|
||||
&.col-2::before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
width: 2px;
|
||||
height: 0;
|
||||
border-top: 1px solid var(--reference-point-border-color);
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
&.row-1::after,
|
||||
&.row-2::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
border-left: 1px solid var(--reference-point-border-color);
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
&.row-1 {
|
||||
top: 3px;
|
||||
}
|
||||
&.col-1 {
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
&.row-2 {
|
||||
top: 10px;
|
||||
}
|
||||
&.col-2 {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
&.row-3 {
|
||||
top: 17px;
|
||||
}
|
||||
&.col-3 {
|
||||
left: 17px;
|
||||
}
|
||||
|
||||
// Click targets that extend 1px beyond the borders of each square
|
||||
div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2px;
|
||||
margin: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.disabled) button:not(.active):hover {
|
||||
border-color: transparent;
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
&.disabled button {
|
||||
--reference-point-border-color: var(--color-4-dimgray);
|
||||
--reference-point-fill-active: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const SELECTION_ENDPOINT_SIZE = 5;
|
||||
const RULER_THICKNESS = 16;
|
||||
const MAJOR_MARK_THICKNESS = 16;
|
||||
const MINOR_MARK_THICKNESS = 6;
|
||||
const MICRO_MARK_THICKNESS = 3;
|
||||
const TAU = 2 * Math.PI;
|
||||
|
||||
type RulerDirection = "Horizontal" | "Vertical";
|
||||
|
||||
export let direction: RulerDirection = "Vertical";
|
||||
export let originX: number;
|
||||
export let originY: number;
|
||||
export let tilt: number;
|
||||
export let flip: boolean = false;
|
||||
export let numberInterval: number;
|
||||
export let majorMarkSpacing: number;
|
||||
export let minorDivisions = 5;
|
||||
export let microDivisions = 2;
|
||||
export let cursorPosition: { x: number; y: number } | undefined = undefined;
|
||||
export let selectionQuad: [number, number][] | undefined = undefined;
|
||||
|
||||
let rulerInput: HTMLDivElement | undefined;
|
||||
let rulerLength = 0;
|
||||
let svgBounds = { width: "0px", height: "0px" };
|
||||
|
||||
type Axis = { sign: number; vec: [number, number] };
|
||||
|
||||
$: axes = computeAxes(tilt);
|
||||
$: isHorizontal = direction === "Horizontal";
|
||||
$: trackedAxis = isHorizontal ? axes.horiz : axes.vert;
|
||||
$: otherAxis = isHorizontal ? axes.vert : axes.horiz;
|
||||
$: crossAxisDirection = flipVector(otherAxis.vec, flip);
|
||||
$: stretchFactor = 1 / Math.max(Math.abs(isHorizontal ? trackedAxis.vec[0] : trackedAxis.vec[1]), 1e-10);
|
||||
$: stretchedSpacing = majorMarkSpacing * stretchFactor;
|
||||
$: effectiveOrigin = projectOntoRuler(direction, originX, originY, crossAxisDirection);
|
||||
$: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, stretchFactor, minorDivisions, microDivisions, rulerLength, crossAxisDirection);
|
||||
$: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, crossAxisDirection);
|
||||
$: cursorIndicatorPath = computeCursorIndicator(direction, cursorPosition, crossAxisDirection);
|
||||
$: selectionExtent = computeSelectionExtent(direction, selectionQuad, crossAxisDirection);
|
||||
|
||||
function computeAxes(tilt: number): { horiz: Axis; vert: Axis } {
|
||||
const normTilt = ((tilt % TAU) + TAU) % TAU;
|
||||
const octant = Math.floor((normTilt + Math.PI / 4) / (Math.PI / 2)) % 4;
|
||||
|
||||
const [c, s] = [Math.cos(tilt), Math.sin(tilt)];
|
||||
const posX: Axis = { sign: 1, vec: [c, s] };
|
||||
const posY: Axis = { sign: 1, vec: [-s, c] };
|
||||
const negX: Axis = { sign: -1, vec: [-c, -s] };
|
||||
const negY: Axis = { sign: -1, vec: [s, -c] };
|
||||
|
||||
if (octant === 0) return { horiz: posX, vert: posY };
|
||||
if (octant === 1) return { horiz: negY, vert: posX };
|
||||
if (octant === 2) return { horiz: negX, vert: negY };
|
||||
return { horiz: posY, vert: negX };
|
||||
}
|
||||
|
||||
function flipVector(vec: [number, number], flipped: boolean): [number, number] {
|
||||
return flipped ? [-vec[0], vec[1]] : vec;
|
||||
}
|
||||
|
||||
function projectOntoRuler(direction: RulerDirection, x: number, y: number, vec: [number, number]): number {
|
||||
const [vx, vy] = vec;
|
||||
if (direction === "Horizontal") return Math.abs(vy) < 1e-10 ? x : x - y * (vx / vy);
|
||||
return Math.abs(vx) < 1e-10 ? y : y - x * (vy / vx);
|
||||
}
|
||||
|
||||
function tickMarkGeometry(direction: RulerDirection, vx: number, vy: number): { dx: number; dy: number; sxBase: number; syBase: number } {
|
||||
const reversal = direction === "Horizontal" ? (vy > 0 ? -1 : 1) : vx > 0 ? -1 : 1;
|
||||
return {
|
||||
dx: vx * reversal,
|
||||
dy: vy * reversal,
|
||||
sxBase: direction === "Horizontal" ? 0 : RULER_THICKNESS,
|
||||
syBase: direction === "Horizontal" ? RULER_THICKNESS : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function computeSvgPath(
|
||||
direction: RulerDirection,
|
||||
effectiveOrigin: number,
|
||||
stretchedSpacing: number,
|
||||
stretchFactor: number,
|
||||
minorDivisions: number,
|
||||
microDivisions: number,
|
||||
rulerLength: number,
|
||||
crossAxisDirection: [number, number],
|
||||
): string {
|
||||
const adaptive = stretchFactor > 1.3 ? { minor: minorDivisions, micro: 1 } : { minor: minorDivisions, micro: microDivisions };
|
||||
const divisions = stretchedSpacing / adaptive.minor / adaptive.micro;
|
||||
const majorMarksFrequency = adaptive.minor * adaptive.micro;
|
||||
const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing;
|
||||
|
||||
const { dx, dy, sxBase, syBase } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
|
||||
|
||||
let path = "";
|
||||
let i = 0;
|
||||
for (let location = shiftedOffsetStart; location < rulerLength + RULER_THICKNESS; location += divisions) {
|
||||
let length;
|
||||
if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS;
|
||||
else if (i % adaptive.micro === 0) length = MINOR_MARK_THICKNESS;
|
||||
else length = MICRO_MARK_THICKNESS;
|
||||
i += 1;
|
||||
|
||||
const destination = Math.round(location) + 0.5;
|
||||
const [sx, sy] = direction === "Horizontal" ? [destination, syBase] : [sxBase, destination];
|
||||
path += `M${sx},${sy}l${dx * length},${dy * length} `;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function computeSvgTexts(
|
||||
direction: RulerDirection,
|
||||
effectiveOrigin: number,
|
||||
stretchedSpacing: number,
|
||||
numberInterval: number,
|
||||
rulerLength: number,
|
||||
trackedAxis: Axis,
|
||||
crossAxisDirection: [number, number],
|
||||
): { transform: string; text: string }[] {
|
||||
const isVertical = direction === "Vertical";
|
||||
|
||||
const { dx: tipDx, dy: tipDy } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
|
||||
const forwardTip = isVertical ? -tipDy : tipDx;
|
||||
const tiltScale = forwardTip >= 0 ? 1 : 0.5;
|
||||
const tipOffsetX = tipDx * MAJOR_MARK_THICKNESS * tiltScale;
|
||||
const tipOffsetY = tipDy * MAJOR_MARK_THICKNESS * tiltScale;
|
||||
|
||||
const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing;
|
||||
const increments = Math.round((shiftedOffsetStart - effectiveOrigin) / stretchedSpacing);
|
||||
let labelNumber = increments * numberInterval * trackedAxis.sign;
|
||||
|
||||
const results: { transform: string; text: string }[] = [];
|
||||
|
||||
for (let location = shiftedOffsetStart; location < rulerLength; location += stretchedSpacing) {
|
||||
const destination = Math.round(location);
|
||||
const x = isVertical ? 9 : destination + 2 + tipOffsetX;
|
||||
const y = isVertical ? destination + 1 + tipOffsetY : 9;
|
||||
|
||||
let transform = `translate(${x} ${y})`;
|
||||
if (isVertical) transform += " rotate(270)";
|
||||
|
||||
const num = Math.abs(labelNumber) < 1e-9 ? 0 : labelNumber;
|
||||
const text = numberInterval >= 1 ? `${num}` : num.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, "");
|
||||
|
||||
results.push({ transform, text });
|
||||
|
||||
labelNumber += numberInterval * trackedAxis.sign;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function computeCursorIndicator(direction: RulerDirection, cursor: { x: number; y: number } | undefined, crossAxisDirection: [number, number]): string {
|
||||
if (cursor === undefined) return "";
|
||||
|
||||
const projected = projectOntoRuler(direction, cursor.x, cursor.y, crossAxisDirection);
|
||||
const { dx, dy, sxBase, syBase } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
|
||||
|
||||
// Scale the line so it spans the full ruler bar thickness
|
||||
const thicknessComponent = Math.abs(direction === "Horizontal" ? dy : dx);
|
||||
const length = thicknessComponent < 1e-10 ? RULER_THICKNESS : RULER_THICKNESS / thicknessComponent;
|
||||
|
||||
const destination = Math.round(projected) + 0.5;
|
||||
const [sx, sy] = direction === "Horizontal" ? [destination, syBase] : [sxBase, destination];
|
||||
return `M${sx},${sy}l${dx * length},${dy * length}`;
|
||||
}
|
||||
|
||||
function computeSelectionExtent(direction: RulerDirection, quad: [number, number][] | undefined, crossAxisDirection: [number, number]): { min: number; max: number } | undefined {
|
||||
if (!quad || quad.length === 0) return undefined;
|
||||
|
||||
const projected = quad.map(([x, y]) => projectOntoRuler(direction, x, y, crossAxisDirection));
|
||||
|
||||
return { min: Math.min(...projected), max: Math.max(...projected) };
|
||||
}
|
||||
|
||||
export function resize() {
|
||||
if (!rulerInput) return;
|
||||
|
||||
const isVertical = direction === "Vertical";
|
||||
|
||||
const newLength = isVertical ? rulerInput.clientHeight : rulerInput.clientWidth;
|
||||
const roundedUp = (Math.floor(newLength / stretchedSpacing) + 2) * stretchedSpacing;
|
||||
|
||||
if (roundedUp !== rulerLength) {
|
||||
rulerLength = roundedUp;
|
||||
const thickness = `${RULER_THICKNESS}px`;
|
||||
const length = `${roundedUp}px`;
|
||||
svgBounds = isVertical ? { width: thickness, height: length } : { width: length, height: thickness };
|
||||
}
|
||||
}
|
||||
|
||||
// Modulo function that works for negative numbers, unlike the JS `%` operator
|
||||
function mod(n: number, m: number): number {
|
||||
const remainder = n % m;
|
||||
return Math.floor(remainder >= 0 ? remainder : remainder + m);
|
||||
}
|
||||
|
||||
onMount(resize);
|
||||
</script>
|
||||
|
||||
<div class="ruler-input">
|
||||
<div class={`ruler-area ${direction === "Horizontal" ? "horizontal" : "vertical"}`} bind:this={rulerInput}>
|
||||
<svg style:width={svgBounds.width} style:height={svgBounds.height}>
|
||||
<path d={svgPath} />
|
||||
{#each svgTexts as svgText}
|
||||
<text transform={svgText.transform}>{svgText.text}</text>
|
||||
{/each}
|
||||
{#if cursorIndicatorPath}
|
||||
<path class="cursor-indicator" d={cursorIndicatorPath} />
|
||||
{/if}
|
||||
</svg>
|
||||
</div>
|
||||
{#if selectionExtent}
|
||||
{@const isVertical = direction === "Vertical"}
|
||||
{@const minPos = Math.round(selectionExtent.min)}
|
||||
{@const maxPos = Math.round(selectionExtent.max)}
|
||||
{@const half = Math.floor(SELECTION_ENDPOINT_SIZE / 2)}
|
||||
{@const overlap = Math.ceil(SELECTION_ENDPOINT_SIZE / 2)}
|
||||
<div class="selection-overlay-container" style:width={isVertical ? `${RULER_THICKNESS + overlap}px` : "100%"} style:height={isVertical ? "100%" : `${RULER_THICKNESS + overlap}px`}>
|
||||
<div
|
||||
class="selection-line"
|
||||
style:left={isVertical ? `${RULER_THICKNESS}px` : `${minPos}px`}
|
||||
style:top={isVertical ? `${minPos}px` : `${RULER_THICKNESS}px`}
|
||||
style:width={isVertical ? "1px" : `${maxPos - minPos}px`}
|
||||
style:height={isVertical ? `${maxPos - minPos}px` : "1px"}
|
||||
></div>
|
||||
{#each [minPos, maxPos] as pos}
|
||||
<div
|
||||
class="selection-endpoint"
|
||||
style:left={isVertical ? `${RULER_THICKNESS - half}px` : `${pos - half}px`}
|
||||
style:top={isVertical ? `${pos - half}px` : `${RULER_THICKNESS - half}px`}
|
||||
style:width={`${SELECTION_ENDPOINT_SIZE}px`}
|
||||
style:height={`${SELECTION_ENDPOINT_SIZE}px`}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.ruler-input {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
|
||||
.ruler-area {
|
||||
background: var(--color-2-mildblack);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.horizontal {
|
||||
height: 16px;
|
||||
border-bottom: 1px solid var(--color-5-dullgray);
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
width: 16px;
|
||||
border-right: 1px solid var(--color-5-dullgray);
|
||||
|
||||
svg text {
|
||||
text-anchor: end;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
|
||||
path {
|
||||
stroke-width: 1px;
|
||||
stroke: var(--color-5-dullgray);
|
||||
|
||||
&.cursor-indicator {
|
||||
stroke: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 12px;
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selection-overlay-container {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.selection-line {
|
||||
position: absolute;
|
||||
background: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.selection-endpoint {
|
||||
position: absolute;
|
||||
background: var(--color-2-mildblack);
|
||||
border: 1px solid var(--color-overlay-blue);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "/src/managers/input";
|
||||
|
||||
const ARROW_CLICK_DISTANCE = 0.05;
|
||||
const ARROW_REPEAT_DISTANCE = 0.01;
|
||||
|
||||
// Convert the position of the thumb (0-1) to the position on the track (0-1).
|
||||
// This includes the 1/2 thumb length gap of the possible thumb position each side so the end of the thumb doesn't go off the track.
|
||||
const lerp = (a: number, b: number, t: number): number => a * (1 - t) + b * t;
|
||||
const thumbToTrack = (thumbLength: number, thumbPosition: number): number => lerp(thumbLength / 2, 1 - thumbLength / 2, thumbPosition);
|
||||
|
||||
const pointerPosition = (e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
|
||||
|
||||
const clamp01 = (value: number): number => Math.min(Math.max(value, 0), 1);
|
||||
|
||||
const dispatch = createEventDispatcher<{ trackShift: number; thumbPosition: number; thumbDragStart: undefined; thumbDragEnd: undefined; thumbDragAbort: undefined }>();
|
||||
|
||||
export let direction: "Horizontal" | "Vertical" = "Vertical";
|
||||
export let thumbPosition = 0.5;
|
||||
export let thumbLength = 0.5;
|
||||
|
||||
let scrollTrack: HTMLDivElement | undefined;
|
||||
let dragging = false;
|
||||
let pressingTrack = false;
|
||||
let pressingArrow = false;
|
||||
let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
let pointerPositionLastFrame = 0;
|
||||
let thumbTop: string | undefined = undefined;
|
||||
let thumbBottom: string | undefined = undefined;
|
||||
let thumbLeft: string | undefined = undefined;
|
||||
let thumbRight: string | undefined = undefined;
|
||||
|
||||
$: start = thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2;
|
||||
$: end = 1 - thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2;
|
||||
$: [thumbTop, thumbBottom, thumbLeft, thumbRight] = direction === "Vertical" ? [`${start * 100}%`, `${end * 100}%`, "0%", "0%"] : ["0%", "0%", `${start * 100}%`, `${end * 100}%`];
|
||||
|
||||
function trackLength(): number | undefined {
|
||||
if (scrollTrack === undefined) return undefined;
|
||||
return direction === "Vertical" ? scrollTrack.clientHeight - thumbLength : scrollTrack.clientWidth;
|
||||
}
|
||||
|
||||
function trackOffset(): number | undefined {
|
||||
if (scrollTrack === undefined) return undefined;
|
||||
return direction === "Vertical" ? scrollTrack.getBoundingClientRect().top : scrollTrack.getBoundingClientRect().left;
|
||||
}
|
||||
|
||||
function dragThumb(e: PointerEvent) {
|
||||
if (dragging) return;
|
||||
|
||||
dragging = true;
|
||||
dispatch("thumbDragStart");
|
||||
pointerPositionLastFrame = pointerPosition(e);
|
||||
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function pressArrow(direction: number) {
|
||||
const sendMove = () => {
|
||||
if (!pressingArrow) return;
|
||||
|
||||
const distance = afterInitialDelay ? ARROW_REPEAT_DISTANCE : ARROW_CLICK_DISTANCE;
|
||||
dispatch("trackShift", -direction * distance);
|
||||
|
||||
if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_RAPID_MS);
|
||||
afterInitialDelay = true;
|
||||
};
|
||||
|
||||
pressingArrow = true;
|
||||
dispatch("thumbDragStart");
|
||||
let afterInitialDelay = false;
|
||||
sendMove();
|
||||
repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS);
|
||||
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function pressTrack(e: PointerEvent) {
|
||||
if (dragging) return;
|
||||
|
||||
const length = trackLength();
|
||||
const offset = trackOffset();
|
||||
if (length === undefined || offset === undefined) return;
|
||||
|
||||
const sendMove = () => {
|
||||
if (!pressingTrack) return;
|
||||
|
||||
const oldPointer = thumbToTrack(thumbLength, thumbPosition) * length + offset;
|
||||
const newPointer = pointerPosition(e);
|
||||
|
||||
// Check if the thumb has reached the cursor position
|
||||
const proposedThumbPosition = (newPointer - offset) / length;
|
||||
if (proposedThumbPosition >= start && proposedThumbPosition <= 1 - end) {
|
||||
// End pressing the track
|
||||
pressingTrack = false;
|
||||
clearTimeout(repeatTimeout);
|
||||
|
||||
// Begin dragging the thumb
|
||||
dragging = true;
|
||||
pointerPositionLastFrame = newPointer;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const move = newPointer - oldPointer < 0 ? 1 : -1;
|
||||
dispatch("trackShift", move);
|
||||
|
||||
if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_MS);
|
||||
afterInitialDelay = true;
|
||||
};
|
||||
|
||||
dispatch("thumbDragStart");
|
||||
pressingTrack = true;
|
||||
let afterInitialDelay = false;
|
||||
sendMove();
|
||||
repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS);
|
||||
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function abortInteraction() {
|
||||
if (pressingTrack || pressingArrow) {
|
||||
pressingTrack = false;
|
||||
pressingArrow = false;
|
||||
clearTimeout(repeatTimeout);
|
||||
dispatch("thumbDragAbort");
|
||||
}
|
||||
|
||||
if (dragging) {
|
||||
dragging = false;
|
||||
dispatch("thumbDragAbort");
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (dragging) dispatch("thumbDragEnd");
|
||||
|
||||
dragging = false;
|
||||
pressingTrack = false;
|
||||
pressingArrow = false;
|
||||
clearTimeout(repeatTimeout);
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (pressingTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pressingArrow) {
|
||||
const target = e.target || undefined;
|
||||
if (!target || !(target instanceof Element)) return;
|
||||
if (!target?.closest?.("[data-scrollbar-arrow]")) {
|
||||
pressingArrow = false;
|
||||
clearTimeout(repeatTimeout);
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragging) {
|
||||
const length = trackLength();
|
||||
if (length === undefined) return;
|
||||
|
||||
const positionPositionThisFrame = pointerPosition(e);
|
||||
const dragDelta = positionPositionThisFrame - pointerPositionLastFrame;
|
||||
const movement = dragDelta / (length * (1 - thumbLength));
|
||||
const newThumbPosition = clamp01(thumbPosition + movement);
|
||||
dispatch("thumbPosition", newThumbPosition);
|
||||
|
||||
pointerPositionLastFrame = positionPositionThisFrame;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
const BUTTONS_RIGHT = 0b0000_0010;
|
||||
if (e.buttons & BUTTONS_RIGHT) abortInteraction();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") abortInteraction();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
});
|
||||
|
||||
function addEvents() {
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("mousedown", onMouseDown);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
clearTimeout(repeatTimeout);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`scrollbar-input ${direction.toLowerCase()}`}>
|
||||
<button class="arrow decrease" on:pointerdown={() => pressArrow(-1)} tabindex="-1" data-scrollbar-arrow></button>
|
||||
<div class="scroll-track" on:pointerdown={pressTrack} bind:this={scrollTrack}>
|
||||
<div class="scroll-thumb" on:pointerdown={dragThumb} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight}></div>
|
||||
</div>
|
||||
<button class="arrow increase" on:pointerdown={() => pressArrow(1)} tabindex="-1" data-scrollbar-arrow></button>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.scrollbar-input {
|
||||
display: flex;
|
||||
flex: 1 1 100%;
|
||||
|
||||
&.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
--arrow-color: var(--color-5-dullgray);
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&:hover {
|
||||
--arrow-color: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
&:hover:active {
|
||||
--arrow-color: var(--color-c-brightgray);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical .arrow.decrease::after {
|
||||
margin: 4px 3px;
|
||||
border-width: 0 5px 8px 5px;
|
||||
border-color: transparent transparent var(--arrow-color) transparent;
|
||||
}
|
||||
|
||||
&.vertical .arrow.increase::after {
|
||||
margin: 4px 3px;
|
||||
border-width: 8px 5px 0 5px;
|
||||
border-color: var(--arrow-color) transparent transparent transparent;
|
||||
}
|
||||
|
||||
&.horizontal .arrow.decrease::after {
|
||||
margin: 3px 4px;
|
||||
border-width: 5px 8px 5px 0;
|
||||
border-color: transparent var(--arrow-color) transparent transparent;
|
||||
}
|
||||
|
||||
&.horizontal .arrow.increase::after {
|
||||
margin: 3px 4px;
|
||||
border-width: 5px 0 5px 8px;
|
||||
border-color: transparent transparent transparent var(--arrow-color);
|
||||
}
|
||||
|
||||
.scroll-track {
|
||||
position: relative;
|
||||
flex: 1 1 100%;
|
||||
|
||||
.scroll-thumb {
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
background: var(--color-4-dimgray);
|
||||
|
||||
&:hover,
|
||||
&.dragging {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,553 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
||||
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import type { SpectrumInputUpdate, SpectrumMarker } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const BUTTON_LEFT = 0;
|
||||
const BUTTON_RIGHT = 2;
|
||||
|
||||
const dispatch = createEventDispatcher<{ update: SpectrumInputUpdate; dragging: boolean }>();
|
||||
|
||||
export let trackCSS: string;
|
||||
export let trackStartCSS: string;
|
||||
export let trackEndCSS: string;
|
||||
export let markers: SpectrumMarker[];
|
||||
export let activeMarkerIndex: number | undefined = 0;
|
||||
export let activeMarkerIsMidpoint = false;
|
||||
export let showMidpoints = true;
|
||||
export let allowInsert = true;
|
||||
export let allowDelete = true;
|
||||
export let allowReorder = true;
|
||||
export let narrow = false;
|
||||
export let disabled = false;
|
||||
|
||||
// Reference to the marker track DOM element so we can convert pointer coordinates to a 0..1 position along the track.
|
||||
let markerTrackElement: LayoutRow | undefined = undefined;
|
||||
|
||||
// Drag state — only TS-local; Rust owns authoritative marker data.
|
||||
// Position the dragged marker (or midpoint) had at drag start, restored if the drag is cancelled.
|
||||
let dragRestorePosition: number | undefined = undefined;
|
||||
// True if this drag began with an insertion (so cancel must delete the inserted marker).
|
||||
let dragInsertedMarker = false;
|
||||
// Active marker selection at drag start, restored if the drag is cancelled.
|
||||
let activeMarkerIndexRestore: number | undefined = undefined;
|
||||
let activeMarkerIsMidpointRestore = false;
|
||||
// Tracks whether a midpoint drag has actually moved by at least one frame, to distinguish click-to-select from drag.
|
||||
let midpointDragged = false;
|
||||
// Mirrors whether Alt is currently held during the drag (the desired state).
|
||||
let duplicateRequested = false;
|
||||
// Mirrors whether a frozen copy currently exists in the gradient (the materialized state).
|
||||
let duplicateActive = false;
|
||||
// Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker`
|
||||
// that would otherwise race the structural change before Rust has reported the dragged marker's new index.
|
||||
let skipNextMove = false;
|
||||
|
||||
function emit(intent: SpectrumInputUpdate) {
|
||||
dispatch("update", intent);
|
||||
}
|
||||
|
||||
function setActive(index: number | undefined, isMidpoint: boolean) {
|
||||
activeMarkerIndex = index;
|
||||
activeMarkerIsMidpoint = isMidpoint;
|
||||
emit({ ActiveMarker: { activeMarkerIndex: index, activeMarkerIsMidpoint: isMidpoint } });
|
||||
}
|
||||
|
||||
function pointerPosition(e: MouseEvent): number | undefined {
|
||||
const rect = markerTrackElement?.div()?.getBoundingClientRect();
|
||||
if (!rect) return undefined;
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
return Math.max(0, Math.min(1, ratio));
|
||||
}
|
||||
|
||||
function clampToNeighbors(index: number, position: number): number {
|
||||
const lower = markers[index - 1]?.position ?? 0;
|
||||
const upper = markers[index + 1]?.position ?? 1;
|
||||
return Math.max(lower, Math.min(upper, position));
|
||||
}
|
||||
|
||||
function markerPointerDown(e: PointerEvent, index: number) {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.button === BUTTON_LEFT) {
|
||||
activeMarkerIndexRestore = activeMarkerIndex;
|
||||
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
|
||||
dragRestorePosition = markers[index].position;
|
||||
dragInsertedMarker = false;
|
||||
// Only offer duplication where new stops are allowed. Don't materialize yet: wait for the first move so an Alt-click without a drag leaves no stray copy.
|
||||
duplicateRequested = allowInsert && e.altKey;
|
||||
duplicateActive = false;
|
||||
setActive(index, false);
|
||||
addEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button === BUTTON_RIGHT && allowDelete) {
|
||||
emit({ DeleteMarker: { index } });
|
||||
}
|
||||
}
|
||||
|
||||
function midpointPointerDown(e: PointerEvent, index: number) {
|
||||
if (disabled) return;
|
||||
if (e.button !== BUTTON_LEFT) return;
|
||||
|
||||
midpointDragged = false;
|
||||
activeMarkerIndexRestore = activeMarkerIndex;
|
||||
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
|
||||
dragRestorePosition = markers[index].midpoint;
|
||||
setActive(index, true);
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function midpointDoubleClick(index: number) {
|
||||
if (disabled || midpointDragged) return;
|
||||
emit({ ResetMidpoint: { index } });
|
||||
}
|
||||
|
||||
function markerDoubleClick(index: number) {
|
||||
if (disabled) return;
|
||||
emit({ ResetMarker: { index } });
|
||||
}
|
||||
|
||||
function trackPointerDown(e: PointerEvent) {
|
||||
if (disabled) return;
|
||||
if (e.button !== BUTTON_LEFT) return;
|
||||
if (!allowInsert) return;
|
||||
|
||||
const position = pointerPosition(e);
|
||||
if (position === undefined) return;
|
||||
|
||||
// Compute the index this marker will land at after Rust inserts it (matches Rust's `insert_stop` logic).
|
||||
let insertIndex = markers.findIndex((m) => m.position > position);
|
||||
if (insertIndex === -1) insertIndex = markers.length;
|
||||
|
||||
emit({ InsertMarker: { position } });
|
||||
|
||||
activeMarkerIndexRestore = activeMarkerIndex;
|
||||
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
|
||||
dragRestorePosition = position;
|
||||
dragInsertedMarker = true;
|
||||
// A stop being created by this drag can't be duplicated; duplication is only for dragging an existing stop.
|
||||
duplicateRequested = false;
|
||||
duplicateActive = false;
|
||||
// Don't dispatch an `ActiveMarker` here. The Rust handler already updates the active marker in response to `InsertMarker` and a duplicate `ActiveMarker` would race the layout update.
|
||||
activeMarkerIndex = insertIndex;
|
||||
activeMarkerIsMidpoint = false;
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function deleteShortcut(e: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
if (activeMarkerIndex === undefined) return;
|
||||
|
||||
stopDrag();
|
||||
if (activeMarkerIsMidpoint) emit({ ResetMidpoint: { index: activeMarkerIndex } });
|
||||
else if (allowDelete) emit({ DeleteMarker: { index: activeMarkerIndex } });
|
||||
}
|
||||
|
||||
// Locate the frozen copy left by a duplicate: the non-dragged marker sitting (within epsilon) at the drag's start position.
|
||||
// Returns undefined if no marker is close enough, so a stale `markers` prop can never delete an unrelated stop.
|
||||
function findDuplicateAnchorIndex(): number | undefined {
|
||||
// The frozen copy is inserted at exactly the drag's start position and round-trips through Rust losslessly, so it matches within this tolerance
|
||||
const DUPLICATE_POSITION_EPSILON = 1e-6;
|
||||
|
||||
if (dragRestorePosition === undefined) return undefined;
|
||||
|
||||
const startPosition = dragRestorePosition;
|
||||
|
||||
let best: number | undefined = undefined;
|
||||
let bestDistance = DUPLICATE_POSITION_EPSILON;
|
||||
|
||||
markers.forEach((marker, index) => {
|
||||
if (index === activeMarkerIndex) return;
|
||||
|
||||
const distance = Math.abs(marker.position - startPosition);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
});
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// Bring the materialized duplicate state in line with whether Alt is currently held, inserting or removing the frozen copy.
|
||||
// Returns whether a structural change was emitted, so callers can skip the next move that would race it.
|
||||
function reconcileDuplicate(): boolean {
|
||||
if (!allowInsert || activeMarkerIndex === undefined || activeMarkerIsMidpoint) return false;
|
||||
|
||||
if (duplicateRequested && !duplicateActive) {
|
||||
// Drop a frozen copy at the drag's start position. The dragged marker stays active and becomes the duplicate being moved.
|
||||
|
||||
if (dragRestorePosition === undefined) return false;
|
||||
|
||||
emit({ InsertDuplicate: { index: activeMarkerIndex, position: dragRestorePosition } });
|
||||
duplicateActive = true;
|
||||
|
||||
return true;
|
||||
} else if (!duplicateRequested && duplicateActive) {
|
||||
// Remove the frozen copy so only the dragged marker remains, as if it had been dragged all along.
|
||||
|
||||
const anchor = findDuplicateAnchorIndex();
|
||||
if (anchor === undefined) return false;
|
||||
|
||||
emit({ RemoveDuplicate: { index: anchor } });
|
||||
duplicateActive = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function moveActiveMarker(e: PointerEvent) {
|
||||
if (disabled || activeMarkerIndex === undefined) return;
|
||||
if (e.buttons === 0) {
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Materialize/remove the frozen copy if Alt's state changed without a key event reconciling it first (e.g. Alt held at drag start).
|
||||
// Skip this frame's move. The next move runs once Rust has reported the dragged marker's new index.
|
||||
if (duplicateRequested !== duplicateActive) {
|
||||
reconcileDuplicate();
|
||||
return;
|
||||
}
|
||||
|
||||
// A key event (Alt press/release) already reconciled. Skip the one move that would race that structural change.
|
||||
if (skipNextMove) {
|
||||
skipNextMove = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let position = pointerPosition(e);
|
||||
if (position === undefined) return;
|
||||
if (!allowReorder) position = clampToNeighbors(activeMarkerIndex, position);
|
||||
|
||||
if (!dragInsertedMarker) dispatch("dragging", true);
|
||||
emit({ MoveMarker: { index: activeMarkerIndex, position } });
|
||||
}
|
||||
|
||||
function moveActiveMidpoint(e: PointerEvent) {
|
||||
if (disabled || activeMarkerIndex === undefined) return;
|
||||
if (e.buttons === 0) {
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const absolute = pointerPosition(e);
|
||||
if (absolute === undefined) return;
|
||||
|
||||
const left = markers[activeMarkerIndex]?.position;
|
||||
const right = markers[activeMarkerIndex + 1]?.position;
|
||||
if (left === undefined || right === undefined) return;
|
||||
const range = right - left;
|
||||
if (range <= 0) return;
|
||||
|
||||
midpointDragged = true;
|
||||
dispatch("dragging", true);
|
||||
emit({ MoveMidpoint: { index: activeMarkerIndex, position: (absolute - left) / range } });
|
||||
}
|
||||
|
||||
function abortDrag() {
|
||||
if (disabled || activeMarkerIndex === undefined) return;
|
||||
|
||||
const dragged = activeMarkerIndex;
|
||||
const anchor = duplicateActive ? findDuplicateAnchorIndex() : undefined;
|
||||
|
||||
if (dragInsertedMarker) {
|
||||
// The dragged marker was created by this drag, so delete it.
|
||||
emit({ DeleteMarker: { index: dragged } });
|
||||
} else if (anchor !== undefined) {
|
||||
// A duplicated pre-existing marker: the frozen copy already sits at the start position, so deleting the dragged copy restores the original.
|
||||
emit({ DeleteMarker: { index: dragged } });
|
||||
} else if (dragRestorePosition !== undefined) {
|
||||
// Plain drag: return the marker (or midpoint) to where it began.
|
||||
if (activeMarkerIsMidpoint) emit({ MoveMidpoint: { index: dragged, position: dragRestorePosition } });
|
||||
else emit({ MoveMarker: { index: dragged, position: dragRestorePosition } });
|
||||
}
|
||||
|
||||
setActive(activeMarkerIndexRestore, activeMarkerIsMidpointRestore);
|
||||
stopDrag();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!duplicateRequested && duplicateActive) reconcileDuplicate();
|
||||
stopDrag();
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
removeEvents();
|
||||
dragRestorePosition = undefined;
|
||||
dragInsertedMarker = false;
|
||||
activeMarkerIndexRestore = undefined;
|
||||
activeMarkerIsMidpointRestore = false;
|
||||
midpointDragged = false;
|
||||
duplicateRequested = false;
|
||||
duplicateActive = false;
|
||||
skipNextMove = false;
|
||||
dispatch("dragging", false);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (activeMarkerIsMidpoint) moveActiveMidpoint(e);
|
||||
else moveActiveMarker(e);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
endDrag();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
const BUTTONS_RIGHT = 0b0000_0010;
|
||||
if (e.buttons & BUTTONS_RIGHT) abortDrag();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
const element = markerTrackElement?.div();
|
||||
if (element) preventEscapeClosingParentFloatingMenu(element);
|
||||
abortDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pressing Alt mid-drag duplicates the marker, leaving a frozen copy where the drag began. Reconcile immediately for instant
|
||||
// feedback, and arm a skip so the next pointer move doesn't race the just-emitted structural change. Only when dragging an
|
||||
// existing stop (not one being created by this drag).
|
||||
if (e.key === "Alt" && allowInsert && !activeMarkerIsMidpoint && !dragInsertedMarker && !duplicateRequested) {
|
||||
duplicateRequested = true;
|
||||
if (reconcileDuplicate()) skipNextMove = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
// Releasing Alt mid-drag removes the frozen copy immediately, as if the marker had been dragged all along (likewise armed to skip the next move).
|
||||
if (e.key === "Alt" && duplicateRequested) {
|
||||
duplicateRequested = false;
|
||||
if (reconcileDuplicate()) skipNextMove = true;
|
||||
}
|
||||
}
|
||||
|
||||
function addEvents() {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
}
|
||||
|
||||
// Map midpoint pairs to absolute track positions for rendering the diamond markers.
|
||||
$: midpointPositions = !showMidpoints || markers.length < 2 ? [] : markers.slice(0, -1).map((marker, i) => marker.position + marker.midpoint * (markers[i + 1].position - marker.position));
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", deleteShortcut);
|
||||
});
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
document.removeEventListener("keydown", deleteShortcut);
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutCol
|
||||
class="spectrum-input"
|
||||
classes={{ narrow, disabled }}
|
||||
styles={{
|
||||
"--gradient-start": trackStartCSS,
|
||||
"--gradient-end": trackEndCSS,
|
||||
"--gradient-stops": trackCSS,
|
||||
}}
|
||||
>
|
||||
<LayoutRow class="gradient-strip" on:pointerdown={trackPointerDown}></LayoutRow>
|
||||
<LayoutRow class="midpoint-track">
|
||||
{#each midpointPositions as midpoint, index}
|
||||
<svg
|
||||
class="midpoint"
|
||||
class:active={index === activeMarkerIndex && activeMarkerIsMidpoint}
|
||||
style:--midpoint-position={midpoint}
|
||||
on:pointerdown={(e) => midpointPointerDown(e, index)}
|
||||
on:dblclick={() => midpointDoubleClick(index)}
|
||||
data-gradient-midpoint
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 8 8"
|
||||
>
|
||||
<polygon points="0,4 4,0 8,4 4,8" />
|
||||
</svg>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
<LayoutRow class="marker-track" bind:this={markerTrackElement}>
|
||||
{#each markers as marker, index}
|
||||
<svg
|
||||
class="marker"
|
||||
class:active={index === activeMarkerIndex && !activeMarkerIsMidpoint}
|
||||
style:--marker-position={marker.position}
|
||||
style:--marker-color={marker.handleColorCSS}
|
||||
on:pointerdown={(e) => markerPointerDown(e, index)}
|
||||
on:dblclick={() => markerDoubleClick(index)}
|
||||
data-gradient-marker
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 12 12"
|
||||
>
|
||||
<path class="inner-fill" d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z" />
|
||||
{#if disabled}
|
||||
<path class="disabled-fill" d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z" />
|
||||
{/if}
|
||||
<path
|
||||
class="outer-border"
|
||||
d="M6,1.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h8c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7L6,1.4M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0z"
|
||||
/>
|
||||
</svg>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.spectrum-input {
|
||||
position: relative;
|
||||
--marker-half-width: 6px;
|
||||
|
||||
.gradient-strip {
|
||||
flex: 0 0 auto;
|
||||
height: 16px;
|
||||
background-image:
|
||||
var(--gradient-stops),
|
||||
// Solid start/end colors on either side so the gradient begins at the center of a marker
|
||||
linear-gradient(var(--gradient-start), var(--gradient-start)),
|
||||
linear-gradient(var(--gradient-end), var(--gradient-end)),
|
||||
var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
calc(100% - 2 * var(--marker-half-width)) 100%,
|
||||
// TODO: Find a solution that avoids visual artifacts where these end colors meet the gradient that appear when viewing with a non-integer zoom or display scaling factor
|
||||
var(--marker-half-width) 100%,
|
||||
var(--marker-half-width) 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
var(--marker-half-width) 0,
|
||||
left 0,
|
||||
right 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, no-repeat, no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&.narrow .gradient-strip {
|
||||
margin-top: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&.disabled .gradient-strip {
|
||||
transition: opacity 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.midpoint-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--marker-half-width);
|
||||
right: var(--marker-half-width);
|
||||
|
||||
.midpoint {
|
||||
position: absolute;
|
||||
margin-left: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
bottom: 0;
|
||||
left: calc(var(--midpoint-position) * 100%);
|
||||
|
||||
polygon {
|
||||
stroke: var(--color-e-nearwhite);
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
|
||||
&.active {
|
||||
z-index: 1;
|
||||
|
||||
polygon {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled .midpoint-track .midpoint polygon {
|
||||
stroke: var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
.marker-track {
|
||||
margin-top: calc(24px - 16px - 12px);
|
||||
margin-left: var(--marker-half-width);
|
||||
width: calc(100% - 2 * var(--marker-half-width));
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
left: calc(var(--marker-position) * 100%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
pointer-events: auto;
|
||||
overflow: visible;
|
||||
padding-top: 12px;
|
||||
margin-top: -12px;
|
||||
|
||||
.inner-fill {
|
||||
fill: var(--marker-color);
|
||||
}
|
||||
|
||||
.outer-border {
|
||||
fill: var(--color-5-dullgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled .marker-track .marker {
|
||||
.disabled-fill {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.outer-border {
|
||||
fill: var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.disabled) .marker-track .marker {
|
||||
&:not(.active) {
|
||||
.inner-fill:hover + .outer-border,
|
||||
.outer-border:hover {
|
||||
fill: var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
z-index: 1;
|
||||
|
||||
.inner-fill {
|
||||
filter: drop-shadow(0 0 1px var(--color-2-mildblack)) drop-shadow(0 0 1px var(--color-2-mildblack));
|
||||
}
|
||||
|
||||
// Outer border when active
|
||||
.outer-border {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.inner-fill:hover + .outer-border,
|
||||
.outer-border:hover {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import FieldInput from "/src/components/widgets/inputs/FieldInput.svelte";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ commitText: string }>();
|
||||
|
||||
export let value: string;
|
||||
export let label: string | undefined = undefined;
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
export let disabled = false;
|
||||
export let monospace = false;
|
||||
|
||||
let self: FieldInput | undefined;
|
||||
let editing = false;
|
||||
|
||||
function onTextFocused() {
|
||||
editing = true;
|
||||
}
|
||||
|
||||
// Called only when `value` is changed from the <textarea> element via user input and committed, either
|
||||
// via the `change` event or when the <input> element is unfocused (with the `blur` event binding)
|
||||
function onTextChanged() {
|
||||
// The `unFocus()` call in `onTextChangeCanceled()` causes itself to be run again, so this if statement skips a second run
|
||||
if (!editing) return;
|
||||
|
||||
onTextChangeCanceled();
|
||||
|
||||
// TODO: Find a less hacky way to do this
|
||||
if (self) dispatch("commitText", self.getValue());
|
||||
|
||||
// Required if value is not changed by the parent component upon update:value event
|
||||
self?.setInputElementValue(self.getValue());
|
||||
}
|
||||
|
||||
function onTextChangeCanceled() {
|
||||
editing = false;
|
||||
|
||||
self?.unFocus();
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
self?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<FieldInput
|
||||
class="text-area-input"
|
||||
classes={{ "has-label": Boolean(label) }}
|
||||
{value}
|
||||
on:value
|
||||
on:textFocused={onTextFocused}
|
||||
on:textChanged={onTextChanged}
|
||||
on:textChangeCanceled={onTextChangeCanceled}
|
||||
textarea={true}
|
||||
spellcheck={true}
|
||||
{label}
|
||||
{disabled}
|
||||
{monospace}
|
||||
{tooltipLabel}
|
||||
{tooltipDescription}
|
||||
{tooltipShortcut}
|
||||
bind:this={self}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import FieldInput from "/src/components/widgets/inputs/FieldInput.svelte";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ commitText: string }>();
|
||||
|
||||
// Content
|
||||
export let value: string;
|
||||
export let label: string | undefined = undefined;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
export let disabled = false;
|
||||
// Styling
|
||||
export let narrow = false;
|
||||
export let centered = false;
|
||||
// Sizing
|
||||
export let minWidth = 0;
|
||||
export let maxWidth = 0;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
let self: FieldInput | undefined;
|
||||
let editing = false;
|
||||
|
||||
function onTextFocused() {
|
||||
editing = true;
|
||||
|
||||
self?.selectAllText(value);
|
||||
}
|
||||
|
||||
// Called only when `value` is changed from the <input> element via user input and committed, either with the
|
||||
// enter key (via the `change` event) or when the <input> element is unfocused (with the `blur` event binding)
|
||||
function onTextChanged() {
|
||||
// The `unFocus()` call in `onTextChangeCanceled()` causes itself to be run again, so this if statement skips a second run
|
||||
if (!editing) return;
|
||||
|
||||
// Capture before `onTextChangeCanceled` blurs the input
|
||||
const currentValue = self?.getValue();
|
||||
|
||||
onTextChangeCanceled();
|
||||
|
||||
// Only commit on a real edit, so a blur fired when the focused input is removed from the DOM (e.g., from a picker closing
|
||||
// during hover transfer) doesn't round-trip the original value back to the backend and overwrite concurrent state.
|
||||
if (self && currentValue !== undefined && currentValue !== value) {
|
||||
dispatch("commitText", currentValue);
|
||||
}
|
||||
|
||||
// Required if value is not changed by the parent component upon update:value event
|
||||
self?.setInputElementValue(self.getValue());
|
||||
}
|
||||
|
||||
function onTextChangeCanceled() {
|
||||
editing = false;
|
||||
|
||||
self?.unFocus();
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
self?.focus();
|
||||
}
|
||||
|
||||
export function element(): HTMLInputElement | HTMLTextAreaElement | undefined {
|
||||
return self?.element();
|
||||
}
|
||||
</script>
|
||||
|
||||
<FieldInput
|
||||
class={`text-input ${className}`.trim()}
|
||||
classes={{ centered, ...classes }}
|
||||
styles={{
|
||||
...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}),
|
||||
...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}),
|
||||
}}
|
||||
{value}
|
||||
on:value
|
||||
on:textFocused={onTextFocused}
|
||||
on:textChanged={onTextChanged}
|
||||
on:textChangeCanceled={onTextChangeCanceled}
|
||||
spellcheck={true}
|
||||
{label}
|
||||
{disabled}
|
||||
{narrow}
|
||||
{tooltipLabel}
|
||||
{tooltipDescription}
|
||||
{tooltipShortcut}
|
||||
{placeholder}
|
||||
bind:this={self}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.text-input {
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.centered {
|
||||
input:not(:focus) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onDestroy } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import type { TooltipStore } from "/src/stores/tooltip";
|
||||
import { sRgba8ContrastingColor, sRgba8Opaque, sRgba8ToHexNoAlpha, sRgba8ToRgbCSS, createSRgba8, createSRgba8FromHsva } from "/src/utility-functions/colors";
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
update: { hue: number; saturation: number; value: number; alpha: number };
|
||||
startHistoryTransaction: undefined;
|
||||
commitHistoryTransaction: undefined;
|
||||
dragStateChange: boolean;
|
||||
}>();
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
|
||||
export let hue: number;
|
||||
export let saturation: number;
|
||||
export let value: number;
|
||||
export let alpha: number;
|
||||
export let isNone: boolean;
|
||||
export let disabled = false;
|
||||
|
||||
// Transient drag state
|
||||
let draggingPickerTrack: HTMLDivElement | undefined = undefined;
|
||||
let shiftPressed = false;
|
||||
let alignedAxis: "saturation" | "value" | undefined = undefined;
|
||||
let hueBeforeDrag = 0;
|
||||
let saturationBeforeDrag = 0;
|
||||
let valueBeforeDrag = 0;
|
||||
let alphaBeforeDrag = 0;
|
||||
let saturationStartOfAxisAlign: number | undefined = undefined;
|
||||
let valueStartOfAxisAlign: number | undefined = undefined;
|
||||
let saturationRestoreWhenShiftReleased: number | undefined = undefined;
|
||||
let valueRestoreWhenShiftReleased: number | undefined = undefined;
|
||||
|
||||
function emitUpdate(h: number, s: number, v: number, a: number) {
|
||||
dispatch("update", { hue: h, saturation: s, value: v, alpha: a });
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
const target = e.target instanceof HTMLElement ? e.target : undefined;
|
||||
draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
|
||||
|
||||
hueBeforeDrag = hue;
|
||||
saturationBeforeDrag = saturation;
|
||||
valueBeforeDrag = value;
|
||||
alphaBeforeDrag = alpha;
|
||||
|
||||
saturationStartOfAxisAlign = undefined;
|
||||
valueStartOfAxisAlign = undefined;
|
||||
saturationRestoreWhenShiftReleased = undefined;
|
||||
valueRestoreWhenShiftReleased = undefined;
|
||||
|
||||
addEvents();
|
||||
|
||||
onPointerMove(e);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
// Just in case the mouseup event is lost
|
||||
if (e.buttons === 0) removeEvents();
|
||||
|
||||
let nextHue = hue;
|
||||
let nextSaturation = saturation;
|
||||
let nextValue = value;
|
||||
let nextAlpha = alpha;
|
||||
|
||||
if (draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
nextSaturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
|
||||
nextValue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
dispatch("dragStateChange", true);
|
||||
|
||||
if (shiftPressed) {
|
||||
const locked = applyAxisLock(nextSaturation, nextValue);
|
||||
nextSaturation = locked.saturation;
|
||||
nextValue = locked.value;
|
||||
}
|
||||
} else if (draggingPickerTrack?.hasAttribute("data-hue-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
nextHue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
dispatch("dragStateChange", true);
|
||||
} else if (draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
nextAlpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
dispatch("dragStateChange", true);
|
||||
}
|
||||
|
||||
emitUpdate(nextHue, nextSaturation, nextValue, nextAlpha);
|
||||
|
||||
if (!e.shiftKey) {
|
||||
shiftPressed = false;
|
||||
alignedAxis = undefined;
|
||||
} else if (!shiftPressed && draggingPickerTrack) {
|
||||
shiftPressed = true;
|
||||
saturationStartOfAxisAlign = saturationBeforeDrag;
|
||||
valueStartOfAxisAlign = valueBeforeDrag;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (draggingPickerTrack) dispatch("commitHistoryTransaction");
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
const BUTTONS_RIGHT = 0b0000_0010;
|
||||
if (e.buttons & BUTTONS_RIGHT) abortDrag();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") abortDrag();
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (e.key === "Shift") {
|
||||
shiftPressed = false;
|
||||
alignedAxis = undefined;
|
||||
|
||||
if (saturationRestoreWhenShiftReleased !== undefined && valueRestoreWhenShiftReleased !== undefined) {
|
||||
emitUpdate(hue, saturationRestoreWhenShiftReleased, valueRestoreWhenShiftReleased, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addEvents() {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
|
||||
dispatch("startHistoryTransaction");
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
draggingPickerTrack = undefined;
|
||||
// The setTimeout is necessary to prevent the FloatingMenu's `escapeCloses` from becoming true immediately upon pressing the Escape key, and thus closing
|
||||
setTimeout(() => dispatch("dragStateChange", false), 0);
|
||||
shiftPressed = false;
|
||||
alignedAxis = undefined;
|
||||
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
}
|
||||
|
||||
function applyAxisLock(s: number, v: number): { saturation: number; value: number } {
|
||||
if (saturationStartOfAxisAlign === undefined || valueStartOfAxisAlign === undefined) return { saturation: s, value: v };
|
||||
|
||||
const deltaSaturation = s - saturationStartOfAxisAlign;
|
||||
const deltaValue = v - valueStartOfAxisAlign;
|
||||
|
||||
saturationRestoreWhenShiftReleased = s;
|
||||
valueRestoreWhenShiftReleased = v;
|
||||
|
||||
if (Math.abs(deltaSaturation) < Math.abs(deltaValue)) {
|
||||
alignedAxis = "saturation";
|
||||
return { saturation: saturationStartOfAxisAlign, value: v };
|
||||
} else {
|
||||
alignedAxis = "value";
|
||||
return { saturation: s, value: valueStartOfAxisAlign };
|
||||
}
|
||||
}
|
||||
|
||||
function abortDrag() {
|
||||
removeEvents();
|
||||
|
||||
emitUpdate(hueBeforeDrag, saturationBeforeDrag, valueBeforeDrag, alphaBeforeDrag);
|
||||
}
|
||||
|
||||
function clamp(input: number, min = 0, max = 1): number {
|
||||
return Math.max(min, Math.min(input, max));
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
});
|
||||
|
||||
$: newColor = isNone ? undefined : createSRgba8FromHsva(hue, saturation, value, alpha);
|
||||
$: opaqueHueColor = createSRgba8FromHsva(hue, 1, 1, 1);
|
||||
$: opaqueColorOnly = newColor ? sRgba8Opaque(newColor) : createSRgba8(0, 0, 0, 255);
|
||||
</script>
|
||||
|
||||
<LayoutRow
|
||||
class="visual-color-pickers-input"
|
||||
classes={{ disabled }}
|
||||
styles={{
|
||||
"--hue-color": sRgba8ToRgbCSS(opaqueHueColor),
|
||||
"--hue-color-contrasting": sRgba8ContrastingColor(opaqueHueColor),
|
||||
"--opaque-color": sRgba8ToHexNoAlpha(opaqueColorOnly),
|
||||
"--opaque-color-contrasting": sRgba8ContrastingColor(opaqueColorOnly),
|
||||
"--new-color-contrasting": sRgba8ContrastingColor(newColor),
|
||||
}}
|
||||
>
|
||||
{@const hueDescription = "The shade along the spectrum of the rainbow."}
|
||||
<LayoutCol
|
||||
class="saturation-value-picker"
|
||||
data-tooltip-label="Saturation and Value"
|
||||
data-tooltip-description={`To move only along the saturation (X) or value (Y) axis, perform the shortcut shown.${disabled ? "\n\nDisabled (read-only)." : ""}`}
|
||||
data-tooltip-shortcut={$tooltip.shiftClickShortcut?.shortcut ? JSON.stringify($tooltip.shiftClickShortcut.shortcut) : undefined}
|
||||
on:pointerdown={onPointerDown}
|
||||
data-saturation-value-picker
|
||||
>
|
||||
{#if alignedAxis}
|
||||
<div
|
||||
class="selection-circle-axis-snap-line"
|
||||
style:width={alignedAxis === "value" ? "100%" : undefined}
|
||||
style:height={alignedAxis === "saturation" ? "100%" : undefined}
|
||||
style:top={alignedAxis === "value" ? `${(1 - value) * 100}%` : undefined}
|
||||
style:left={alignedAxis === "saturation" ? `${saturation * 100}%` : undefined}
|
||||
></div>
|
||||
<div
|
||||
class="selection-circle-axis-snap-line"
|
||||
style:width={alignedAxis === "saturation" ? "100%" : undefined}
|
||||
style:height={alignedAxis === "value" ? "100%" : undefined}
|
||||
style:top={alignedAxis === "saturation" ? `${(1 - valueBeforeDrag) * 100}%` : undefined}
|
||||
style:left={alignedAxis === "value" ? `${saturationBeforeDrag * 100}%` : undefined}
|
||||
></div>
|
||||
{/if}
|
||||
{#if !isNone}
|
||||
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`}></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="hue-picker" data-tooltip-label="Hue" data-tooltip-description={`${hueDescription}${disabled ? "\n\nDisabled (read-only)." : ""}`} on:pointerdown={onPointerDown} data-hue-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-needle" style:top={`${(1 - hue) * 100}%`}></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol
|
||||
class="alpha-picker"
|
||||
data-tooltip-label="Alpha"
|
||||
data-tooltip-description={`The level of translucency.${disabled ? "\n\nDisabled (read-only)." : ""}`}
|
||||
on:pointerdown={onPointerDown}
|
||||
data-alpha-picker
|
||||
>
|
||||
{#if !isNone}
|
||||
<div class="selection-needle" style:top={`${(1 - alpha) * 100}%`}></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.visual-color-pickers-input {
|
||||
--picker-size: 256px;
|
||||
--picker-circle-radius: 6px;
|
||||
|
||||
.saturation-value-picker {
|
||||
width: var(--picker-size);
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.saturation-value-picker,
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
height: var(--picker-size);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
width: 24px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hue-picker {
|
||||
--selection-needle-color: var(--hue-color-contrasting);
|
||||
background-blend-mode: screen;
|
||||
background:
|
||||
// Reds
|
||||
linear-gradient(to top, #ff0000ff calc(100% / 6), #ff000000 calc(200% / 6), #ff000000 calc(400% / 6), #ff0000ff calc(500% / 6)),
|
||||
// Greens
|
||||
linear-gradient(to top, #00ff0000 0%, #00ff00ff calc(100% / 6), #00ff00ff 50%, #00ff0000 calc(400% / 6)),
|
||||
// Blues
|
||||
linear-gradient(to top, #0000ff00 calc(200% / 6), #0000ffff 50%, #0000ffff calc(500% / 6), #0000ff00 100%);
|
||||
}
|
||||
|
||||
.alpha-picker {
|
||||
--selection-needle-color: var(--new-color-contrasting);
|
||||
background-image: linear-gradient(to bottom, var(--opaque-color), transparent), var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
100% 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
0 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
.selection-circle {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
left: calc(-1 * var(--picker-circle-radius));
|
||||
top: calc(-1 * var(--picker-circle-radius));
|
||||
width: calc(var(--picker-circle-radius) * 2 + 1px);
|
||||
height: calc(var(--picker-circle-radius) * 2 + 1px);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--opaque-color-contrasting);
|
||||
background: var(--opaque-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-circle-axis-snap-line {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: var(--opaque-color-contrasting);
|
||||
|
||||
+ .selection-circle-axis-snap-line {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-needle {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent var(--selection-needle-color);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent var(--selection-needle-color) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled :is(.saturation-value-picker, .hue-picker, .alpha-picker) {
|
||||
transition: opacity 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpadding
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import { fillChoiceUIColor, sRgba8ToRgbaCSS } from "/src/utility-functions/colors";
|
||||
import type { SRGBA8, EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
|
||||
// Content
|
||||
export let primary: SRGBA8;
|
||||
export let secondary: SRGBA8;
|
||||
|
||||
let primaryOpen = false;
|
||||
let secondaryOpen = false;
|
||||
|
||||
function clickPrimarySwatch() {
|
||||
primaryOpen = true;
|
||||
secondaryOpen = false;
|
||||
}
|
||||
|
||||
function clickSecondarySwatch() {
|
||||
primaryOpen = false;
|
||||
secondaryOpen = true;
|
||||
}
|
||||
|
||||
function primaryColorChanged(color: SRGBA8) {
|
||||
editor.updatePrimaryColor(color);
|
||||
}
|
||||
|
||||
function secondaryColorChanged(color: SRGBA8) {
|
||||
editor.updateSecondaryColor(color);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="working-colors-button">
|
||||
<LayoutRow class="primary swatch">
|
||||
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={sRgba8ToRgbaCSS(primary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button>
|
||||
<ColorPicker
|
||||
open={primaryOpen}
|
||||
on:open={({ detail }) => (primaryOpen = detail)}
|
||||
colorOrGradient={{ Solid: primary }}
|
||||
on:colorOrGradient={({ detail }) => {
|
||||
const color = fillChoiceUIColor(detail);
|
||||
if (color) primaryColorChanged(color);
|
||||
}}
|
||||
direction="Right"
|
||||
/>
|
||||
</LayoutRow>
|
||||
<LayoutRow class="secondary swatch">
|
||||
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={sRgba8ToRgbaCSS(secondary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button>
|
||||
<ColorPicker
|
||||
open={secondaryOpen}
|
||||
on:open={({ detail }) => (secondaryOpen = detail)}
|
||||
colorOrGradient={{ Solid: secondary }}
|
||||
on:colorOrGradient={({ detail }) => {
|
||||
const color = fillChoiceUIColor(detail);
|
||||
if (color) secondaryColorChanged(color);
|
||||
}}
|
||||
direction="Right"
|
||||
/>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.working-colors-button {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 2px;
|
||||
position: relative;
|
||||
|
||||
> button {
|
||||
--swatch-color: #ffffff;
|
||||
--ring-color: var(--color-5-dullgray);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
// Color of the panel background, used to extend outside the ring and appear to cut out a crescent from the lower circle (by covering it up with the panel background color)
|
||||
box-shadow: 0 0 0 2px var(--color-3-darkgray);
|
||||
background: var(--color-3-darkgray);
|
||||
|
||||
// Main color and checked transparency pattern (inset by 1px to begin inside/below the ring to avoid antialiasing artifacts)
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
100% 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
0 0,
|
||||
var(--color-transparent-checkered-background-position-plus-one);
|
||||
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
// Gray ring outline
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px var(--ring-color);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
--ring-color: var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-menu {
|
||||
top: 50%;
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
margin-bottom: -8px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import { ICONS, ICON_SVG_STRINGS } from "/src/icons";
|
||||
import type { IconName } from "/src/icons";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
export let iconSizeOverride: number | undefined = undefined;
|
||||
|
||||
// Content
|
||||
export let icon: IconName;
|
||||
export let disabled = false;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
|
||||
$: iconSizeClass = ((icon: IconName) => {
|
||||
const iconData = ICONS[icon];
|
||||
if (!iconData) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Icon "${icon}" does not exist.`);
|
||||
return "size-24";
|
||||
}
|
||||
if (iconData.size === undefined) return "";
|
||||
return `size-${iconSizeOverride || iconData.size}`;
|
||||
})(icon);
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<LayoutRow class={`icon-label ${iconSizeClass} ${className} ${extraClasses}`.trim()} classes={{ disabled }} {tooltipLabel} {tooltipDescription} {tooltipShortcut}>
|
||||
{@html ICON_SVG_STRINGS[icon] || "�"}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.icon-label {
|
||||
flex: 0 0 auto;
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
&.disabled {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.size-12 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.size-16 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.size-24 {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
// Content
|
||||
export let url: string;
|
||||
export let width: string | undefined;
|
||||
export let height: string | undefined;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<img
|
||||
src={url}
|
||||
style:width
|
||||
style:height
|
||||
class={`image-label ${className} ${extraClasses}`.trim()}
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.image-label {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 2px;
|
||||
background-image: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-repeat: var(--color-transparent-checkered-background-repeat);
|
||||
|
||||
+ .image-label.image-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import type { SeparatorDirection, SeparatorStyle } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Content
|
||||
export let direction: SeparatorDirection = "Horizontal";
|
||||
export let style: SeparatorStyle = "Unrelated";
|
||||
</script>
|
||||
|
||||
<div class={`separator ${direction.toLowerCase()} ${style.toLowerCase()}`}>
|
||||
{#if style === "Section"}
|
||||
<div></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.separator {
|
||||
&.vertical {
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.related {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&.unrelated {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.section {
|
||||
// If changing this, update `--height-of-separator` in `Document.svelte`
|
||||
margin: 12px 0;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
margin: 0 4px;
|
||||
height: 1px;
|
||||
width: calc(100% - 8px);
|
||||
background: var(--color-5-dullgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.related {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&.unrelated {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&.section {
|
||||
margin: 0 12px;
|
||||
height: 100%;
|
||||
|
||||
div {
|
||||
margin: 4px 0;
|
||||
height: calc(100% - 8px);
|
||||
width: 1px;
|
||||
background: var(--color-5-dullgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { IconName } from "/src/icons";
|
||||
import { operatingSystem } from "/src/utility-functions/platform";
|
||||
import type { ActionShortcut, Key, LabeledShortcut, MouseMotion } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Content
|
||||
export let shortcut: ActionShortcut;
|
||||
|
||||
function keyTextOrIconList(keyGroup: LabeledShortcut): ({ label?: string; icon?: IconName }[] | { mouseMotion?: MouseMotion }[])[] {
|
||||
const list = keyGroup.map((labeledKeyOrMouseMotion): { label?: string; icon?: IconName; mouseMotion?: MouseMotion } => {
|
||||
// Use a mouse icon if it's a mouse motion instead of a key
|
||||
if (typeof labeledKeyOrMouseMotion === "string") return { mouseMotion: labeledKeyOrMouseMotion };
|
||||
|
||||
// `key` is the name of the `Key` enum in Rust, while `label` is the localized string to display (if it doesn't become an icon)
|
||||
let key: Key | "Option" = labeledKeyOrMouseMotion.key;
|
||||
const label = labeledKeyOrMouseMotion.label;
|
||||
|
||||
// Replace Alt and Accel keys with their Mac-specific equivalents
|
||||
if (operatingSystem() === "Mac") {
|
||||
if (key === "Alt") key = "Option";
|
||||
if (key === "Accel") key = "Command";
|
||||
}
|
||||
|
||||
// Either display an icon...
|
||||
const icon = keyboardHintIcon(key);
|
||||
if (icon) return { icon };
|
||||
|
||||
// ...or display text
|
||||
return { label };
|
||||
});
|
||||
|
||||
// Consolidate consecutive labels into a concatenated single label
|
||||
const consolidatedList: ReturnType<typeof keyTextOrIconList> = [];
|
||||
list.forEach((currentItem) => {
|
||||
const lastGroup = consolidatedList.length > 0 ? consolidatedList[consolidatedList.length - 1] : undefined;
|
||||
const lastItem = lastGroup !== undefined ? lastGroup[lastGroup.length - 1] : undefined;
|
||||
|
||||
// If current and last are both labels, concatenate both within their existing label
|
||||
if (currentItem.label && lastItem && "label" in lastItem && lastItem.label) {
|
||||
lastItem.label += " " + currentItem.label;
|
||||
return;
|
||||
}
|
||||
|
||||
// If current and last are both of the same group type (both icons/labels, or both mouseMotion), join them within their existing
|
||||
if (lastItem && (((currentItem.label || currentItem.icon) && ("label" in lastItem || "icon" in lastItem)) || (currentItem.mouseMotion && "mouseMotion" in lastItem))) {
|
||||
lastGroup?.push(currentItem);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, start a new group with the first item of its group type
|
||||
consolidatedList.push([currentItem]);
|
||||
});
|
||||
return consolidatedList;
|
||||
}
|
||||
|
||||
function keyboardHintIcon(input: Key | "Option"): IconName | undefined {
|
||||
switch (input) {
|
||||
case "ArrowDown":
|
||||
return "KeyboardArrowDown";
|
||||
case "ArrowLeft":
|
||||
return "KeyboardArrowLeft";
|
||||
case "ArrowRight":
|
||||
return "KeyboardArrowRight";
|
||||
case "ArrowUp":
|
||||
return "KeyboardArrowUp";
|
||||
case "Backspace":
|
||||
return "KeyboardBackspace";
|
||||
case "Enter":
|
||||
return "KeyboardEnter";
|
||||
case "Space":
|
||||
return "KeyboardSpace";
|
||||
case "Tab":
|
||||
return "KeyboardTab";
|
||||
case "Command":
|
||||
return operatingSystem() === "Mac" ? "KeyboardCommand" : undefined;
|
||||
case "Control":
|
||||
return operatingSystem() === "Mac" ? "KeyboardControl" : undefined;
|
||||
case "Option":
|
||||
return operatingSystem() === "Mac" ? "KeyboardOption" : undefined;
|
||||
case "Shift":
|
||||
return operatingSystem() === "Mac" ? "KeyboardShift" : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mouseHintIcon(input: MouseMotion): IconName {
|
||||
return {
|
||||
None: "MouseHintNone" as const,
|
||||
Lmb: "MouseHintLmb" as const,
|
||||
Rmb: "MouseHintRmb" as const,
|
||||
Mmb: "MouseHintMmb" as const,
|
||||
ScrollUp: "MouseHintScrollUp" as const,
|
||||
ScrollDown: "MouseHintScrollDown" as const,
|
||||
Drag: "MouseHintDrag" as const,
|
||||
LmbDouble: "MouseHintLmbDouble" as const,
|
||||
LmbDrag: "MouseHintLmbDrag" as const,
|
||||
RmbDrag: "MouseHintRmbDrag" as const,
|
||||
RmbDouble: "MouseHintRmbDouble" as const,
|
||||
MmbDrag: "MouseHintMmbDrag" as const,
|
||||
}[input];
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="shortcut-label">
|
||||
{#each keyTextOrIconList(shortcut.shortcut) as group}
|
||||
{#if "label" in group[0] || "icon" in group[0]}
|
||||
<div class="key-label">
|
||||
{#each group as item}
|
||||
{#if "label" in item && item.label}
|
||||
<TextLabel>{item.label}</TextLabel>
|
||||
{:else if "icon" in item && item.icon}
|
||||
<IconLabel icon={item.icon} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if "mouseMotion" in group[0]}
|
||||
{#each group as item}
|
||||
{#if "mouseMotion" in item && item.mouseMotion}
|
||||
<div class="mouse-icon">
|
||||
<IconLabel icon={mouseHintIcon(item.mouseMotion)} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.shortcut-label {
|
||||
align-items: center;
|
||||
|
||||
.key-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-3-darkgray);
|
||||
color: var(--color-b-lightgray);
|
||||
fill: var(--color-b-lightgray);
|
||||
|
||||
* + * {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-b-lightgray);
|
||||
|
||||
.dim {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.floating-menu-content .row > .shortcut-label {
|
||||
.key-label,
|
||||
.mouse-icon {
|
||||
color: var(--color-8-uppergray);
|
||||
background: none;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.key-label svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.mouse-icon svg {
|
||||
// 3 shades brighter than the 8-uppergray of key labels/icons
|
||||
fill: var(--color-b-lightgray);
|
||||
|
||||
.dim {
|
||||
// 3 shades darker than the 8-uppergray of key labels/icons
|
||||
fill: var(--color-5-dullgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
|
||||
// Content
|
||||
// `value` is passed via slot
|
||||
export let disabled = false;
|
||||
export let forCheckbox: bigint | undefined = undefined;
|
||||
// Styling
|
||||
export let narrow = false;
|
||||
export let bold = false;
|
||||
export let italic = false;
|
||||
export let monospace = false;
|
||||
export let multiline = false;
|
||||
export let centerAlign = false;
|
||||
export let tableAlign = false;
|
||||
// Sizing
|
||||
export let minWidth = 0;
|
||||
export let maxWidth = 0;
|
||||
export let minWidthCharacters = 0;
|
||||
// Tooltips
|
||||
export let tooltipLabel: string | undefined = undefined;
|
||||
export let tooltipDescription: string | undefined = undefined;
|
||||
export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
|
||||
let self: HTMLLabelElement | undefined;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
$: watchForCheckbox(forCheckbox);
|
||||
|
||||
function watchForCheckbox(forCheckbox: bigint | undefined) {
|
||||
if (!self) return;
|
||||
|
||||
self.removeEventListener("pointerenter", handlePointerEnter);
|
||||
self.removeEventListener("pointerleave", handlePointerLeave);
|
||||
|
||||
if (forCheckbox !== undefined) {
|
||||
self.addEventListener("pointerenter", handlePointerEnter);
|
||||
self.addEventListener("pointerleave", handlePointerLeave);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerEnter() {
|
||||
document.querySelector(`[for="checkbox-input-${forCheckbox}"]`)?.classList.add("label-is-hovered");
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
document.querySelector(`[for="checkbox-input-${forCheckbox}"]`)?.classList.remove("label-is-hovered");
|
||||
}
|
||||
|
||||
onMount(() => watchForCheckbox(forCheckbox));
|
||||
onDestroy(() => {
|
||||
handlePointerLeave();
|
||||
watchForCheckbox(undefined);
|
||||
});
|
||||
</script>
|
||||
|
||||
<label
|
||||
class={`text-label ${className} ${extraClasses}`.trim()}
|
||||
class:disabled
|
||||
class:narrow
|
||||
class:bold
|
||||
class:italic
|
||||
class:monospace
|
||||
class:multiline
|
||||
class:center-align={centerAlign}
|
||||
class:table-align={tableAlign}
|
||||
style:min-width={minWidthCharacters ? `${minWidthCharacters}ch` : minWidth > 0 ? `${minWidth}px` : undefined}
|
||||
style:max-width={maxWidth > 0 ? `${maxWidth}px` : undefined}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
data-tooltip-label={tooltipLabel}
|
||||
data-tooltip-description={tooltipDescription}
|
||||
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
|
||||
for={forCheckbox !== undefined ? `checkbox-input-${forCheckbox}` : undefined}
|
||||
on:dblclick
|
||||
bind:this={self}
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
|
||||
<style lang="scss">
|
||||
.text-label {
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
// Force Safari to not draw a text cursor, even though this element has `user-select: none`
|
||||
cursor: default;
|
||||
|
||||
&.narrow.narrow {
|
||||
--widget-height: 20px;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&.monospace,
|
||||
code {
|
||||
font-family: "Source Code Pro", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.multiline {
|
||||
white-space: pre-wrap;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
&.center-align {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.table-align {
|
||||
flex: 0 0 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--color-3-darkgray);
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import Dialog from "/src/components/floating-menus/Dialog.svelte";
|
||||
import Tooltip from "/src/components/floating-menus/Tooltip.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import PanelSubdivision from "/src/components/window/PanelSubdivision.svelte";
|
||||
import StatusBar from "/src/components/window/StatusBar.svelte";
|
||||
import TitleBar from "/src/components/window/TitleBar.svelte";
|
||||
import type { AppWindowStore } from "/src/stores/app-window";
|
||||
import type { DialogStore } from "/src/stores/dialog";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import type { TooltipStore } from "/src/stores/tooltip";
|
||||
|
||||
const dialog = getContext<DialogStore>("dialog");
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
const appWindow = getContext<AppWindowStore>("appWindow");
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
</script>
|
||||
|
||||
<LayoutCol class="main-window" classes={{ "viewport-hole-punch": $appWindow.viewportHolePunch }}>
|
||||
{#if !($appWindow.platform == "Mac" && $appWindow.fullscreen)}
|
||||
<TitleBar />
|
||||
{/if}
|
||||
<LayoutRow class="workspace" data-workspace>
|
||||
<PanelSubdivision subdivision={$portfolio.panelLayout.root} depth={0} />
|
||||
</LayoutRow>
|
||||
<StatusBar />
|
||||
{#if $dialog.visible}
|
||||
<Dialog />
|
||||
{/if}
|
||||
{#if $tooltip.visible}
|
||||
<Tooltip />
|
||||
{/if}
|
||||
{#if import.meta.env.MODE === "native" && new Date() > new Date("2026-12-31")}
|
||||
<LayoutCol class="release-candidate-expiry">
|
||||
<TextLabel>
|
||||
<p>
|
||||
This is an outdated desktop release candidate build. Its testing<br />
|
||||
period has concluded and the next build is available for download.<br />
|
||||
Please update to help us continue testing by reporting new issues.
|
||||
</p>
|
||||
</TextLabel>
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.main-window {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.release-candidate-expiry {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--color-e-nearwhite);
|
||||
color: var(--color-2-mildblack);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
padding: 12px 40px;
|
||||
border-radius: 4px;
|
||||
text-align-last: justify;
|
||||
font-size: 18px;
|
||||
z-index: 1000;
|
||||
|
||||
.text-label {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,705 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy, tick } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import Data from "/src/components/panels/Data.svelte";
|
||||
import Document from "/src/components/panels/Document.svelte";
|
||||
import Layers from "/src/components/panels/Layers.svelte";
|
||||
import Properties from "/src/components/panels/Properties.svelte";
|
||||
import Welcome from "/src/components/panels/Welcome.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover, updateDockingHover } from "/src/stores/panel-drag";
|
||||
import type { DockingEdge } from "/src/stores/panel-drag";
|
||||
import type { DockingSplitDirection, EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const PANEL_COMPONENTS = {
|
||||
Welcome,
|
||||
Document,
|
||||
Layers,
|
||||
Properties,
|
||||
Data,
|
||||
};
|
||||
const BUTTON_LEFT = 0;
|
||||
const BUTTON_MIDDLE = 1;
|
||||
const BUTTON_RIGHT = 2;
|
||||
const DRAG_ACTIVATION_DISTANCE = 5;
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
|
||||
export let tabMinWidths = false;
|
||||
export let tabCloseButtons = false;
|
||||
export let tabLabels: { name: string; unsaved?: boolean; tooltipLabel?: string; tooltipDescription?: string; tooltipShortcut?: string }[];
|
||||
export let tabActiveIndex: number;
|
||||
export let panelTypes: PanelType[];
|
||||
export let panelId: string;
|
||||
export let clickAction: ((index: number) => void) | undefined = undefined;
|
||||
export let closeAction: ((index: number) => void) | undefined = undefined;
|
||||
export let reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined;
|
||||
export let renameAction: ((index: number, newName: string) => void) | undefined = undefined;
|
||||
export let emptySpaceAction: (() => void) | undefined = undefined;
|
||||
export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
|
||||
export let groupDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
|
||||
export let splitDropAction: ((targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) => void) | undefined = undefined;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
|
||||
let tabElements: (LayoutRow | undefined)[] = [];
|
||||
|
||||
// Document tab rename state
|
||||
let editingNameTabIndex: number | undefined = undefined;
|
||||
let editingNameText = "";
|
||||
let editingNameInputElement: HTMLInputElement | undefined = undefined;
|
||||
|
||||
async function setEditingTabName(tabIndex: number, currentName: string) {
|
||||
editingNameText = currentName;
|
||||
editingNameTabIndex = tabIndex;
|
||||
|
||||
await tick();
|
||||
|
||||
editingNameInputElement?.focus();
|
||||
editingNameInputElement?.select();
|
||||
}
|
||||
|
||||
function commitEditingTabName(event: Event) {
|
||||
if (editingNameTabIndex === undefined || !(event.target instanceof HTMLInputElement)) return;
|
||||
|
||||
renameAction?.(editingNameTabIndex, event.target.value);
|
||||
editingNameTabIndex = undefined;
|
||||
}
|
||||
|
||||
// Tab drag-and-drop state
|
||||
let dragStartState: { tabIndex: number; pointerX: number; pointerY: number; isGroupDrag: boolean } | undefined = undefined;
|
||||
let dragging = false;
|
||||
let insertionIndex: number | undefined = undefined;
|
||||
let insertionMarkerLeft: number | undefined = undefined;
|
||||
let lastPointerX = 0;
|
||||
let tabGroupElement: LayoutRow | undefined = undefined;
|
||||
|
||||
onDestroy(() => {
|
||||
endDrag();
|
||||
});
|
||||
|
||||
function onEmptySpaceAction(e: MouseEvent) {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.button === BUTTON_MIDDLE || (e.button === BUTTON_LEFT && e.detail === 2)) emptySpaceAction?.();
|
||||
}
|
||||
|
||||
function tabBarPointerDown(e: PointerEvent) {
|
||||
// Only start a group drag from the tab bar background (not from a tab or button)
|
||||
if (e.button !== BUTTON_LEFT) return;
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (!crossPanelDropAction && !splitDropAction) return;
|
||||
|
||||
dragStartState = { tabIndex: tabActiveIndex, pointerX: e.clientX, pointerY: e.clientY, isGroupDrag: true };
|
||||
dragging = false;
|
||||
insertionIndex = undefined;
|
||||
insertionMarkerLeft = undefined;
|
||||
|
||||
addDragListeners();
|
||||
}
|
||||
|
||||
export async function scrollTabIntoView(newIndex: number) {
|
||||
await tick();
|
||||
tabElements[newIndex]?.div?.()?.scrollIntoView();
|
||||
}
|
||||
|
||||
// Tab drag-and-drop handlers
|
||||
|
||||
function tabPointerDown(e: PointerEvent, tabIndex: number) {
|
||||
if (e.button !== BUTTON_LEFT) return;
|
||||
if (e.target instanceof Element && e.target.closest("[data-close-button]")) return;
|
||||
|
||||
// Activate the tab upon pointer down
|
||||
clickAction?.(tabIndex);
|
||||
|
||||
// Allow within-panel reorder if there are multiple tabs, or cross-panel drag if this panel supports docking
|
||||
const canReorder = reorderAction && tabLabels.length > 1;
|
||||
const canCrossPanelDrag = crossPanelDropAction !== undefined;
|
||||
if (!canReorder && !canCrossPanelDrag) return;
|
||||
|
||||
dragStartState = { tabIndex, pointerX: e.clientX, pointerY: e.clientY, isGroupDrag: false };
|
||||
dragging = false;
|
||||
insertionIndex = undefined;
|
||||
insertionMarkerLeft = undefined;
|
||||
|
||||
addDragListeners();
|
||||
}
|
||||
|
||||
function dragPointerMove(e: PointerEvent) {
|
||||
if (!dragStartState) return;
|
||||
|
||||
// Activate drag after moving beyond threshold
|
||||
if (!dragging) {
|
||||
const deltaX = Math.abs(e.clientX - dragStartState.pointerX);
|
||||
const deltaY = Math.abs(e.clientY - dragStartState.pointerY);
|
||||
if (deltaX < DRAG_ACTIVATION_DISTANCE && deltaY < DRAG_ACTIVATION_DISTANCE) return;
|
||||
|
||||
dragging = true;
|
||||
|
||||
// Group drags enter cross-panel state for edge docking even without crossPanelDropAction
|
||||
if (dragStartState.isGroupDrag && (crossPanelDropAction || splitDropAction)) {
|
||||
startCrossPanelDrag(panelId, [...panelTypes], tabActiveIndex, true);
|
||||
} else if (!dragStartState.isGroupDrag && crossPanelDropAction) {
|
||||
const draggedTab = panelTypes[dragStartState.tabIndex];
|
||||
startCrossPanelDrag(panelId, [draggedTab], dragStartState.tabIndex, false);
|
||||
}
|
||||
}
|
||||
|
||||
lastPointerX = e.clientX;
|
||||
|
||||
// Exit early in here after we show the insertion marker, if we're within our own tab bar
|
||||
if (pointerIsInsideTabBar(e)) {
|
||||
calculateInsertionIndex(lastPointerX);
|
||||
updateCrossPanelHover(undefined, undefined, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear local insertion marker since we're outside our own tab bar
|
||||
insertionIndex = undefined;
|
||||
insertionMarkerLeft = undefined;
|
||||
|
||||
// Skip cross-panel hover detection for sources that can't dock anywhere
|
||||
if (!crossPanelDropAction && !splitDropAction) return;
|
||||
|
||||
if (crossPanelDropAction) {
|
||||
const tabBarTarget = Array.from(document.querySelectorAll("[data-panel-tab-bar]")).find((element) => {
|
||||
const targetPanelId = element.getAttribute("data-panel-tab-bar");
|
||||
if (!targetPanelId || targetPanelId === panelId) return false;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
});
|
||||
|
||||
const tabBarTargetId = tabBarTarget?.getAttribute("data-panel-tab-bar");
|
||||
if (tabBarTarget instanceof HTMLDivElement && tabBarTargetId) {
|
||||
calculateForeignInsertionIndex(e.clientX, tabBarTargetId, tabBarTarget);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for edge-zone split docking
|
||||
const panelBody = Array.from(document.querySelectorAll("[data-panel-body]")).find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
});
|
||||
|
||||
const bodyPanelId = panelBody && panelBody.getAttribute("data-panel-body");
|
||||
if (bodyPanelId) {
|
||||
const rect = panelBody.getBoundingClientRect();
|
||||
let edge: DockingEdge | undefined = detectDockingEdge(e.clientX, e.clientY, rect);
|
||||
|
||||
// Center drops between different panels require both to be cross-panel-dockable (self-drops are always allowed as a no-op)
|
||||
if (edge === "Center" && bodyPanelId !== panelId) {
|
||||
const targetIsDockable = panelBody.hasAttribute("data-panel-dockable");
|
||||
const sourceIsDockable = crossPanelDropAction !== undefined;
|
||||
if (!sourceIsDockable || !targetIsDockable) edge = undefined;
|
||||
}
|
||||
|
||||
if (edge) {
|
||||
updateDockingHover(bodyPanelId, edge);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not hovering any drop target
|
||||
updateCrossPanelHover(undefined, undefined, undefined);
|
||||
updateDockingHover(undefined, undefined);
|
||||
}
|
||||
|
||||
function dragPointerUp() {
|
||||
if (dragging && dragStartState) {
|
||||
const crossPanelState = $panelDrag;
|
||||
|
||||
// Center drop: append tabs to the target panel group
|
||||
if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge === "Center") {
|
||||
const dropAction = crossPanelState.draggingGroup ? groupDropAction : crossPanelDropAction;
|
||||
dropAction?.(panelId, crossPanelState.hoverDockingPanelId, Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
// Edge docking drop: create a new split adjacent to the target panel
|
||||
else if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge && crossPanelState.hoverDockingEdge !== "Center") {
|
||||
splitDropAction?.(
|
||||
crossPanelState.hoverDockingPanelId,
|
||||
crossPanelState.hoverDockingEdge,
|
||||
crossPanelState.draggedTabs,
|
||||
crossPanelState.draggingGroup ? crossPanelState.sourceTabIndex : 0,
|
||||
);
|
||||
}
|
||||
// Cross-panel tab bar drop: insert as a tab in the target panel group
|
||||
else if (
|
||||
crossPanelDropAction &&
|
||||
crossPanelState.active &&
|
||||
crossPanelState.hoverTargetPanelId &&
|
||||
crossPanelState.hoverTargetPanelId !== panelId &&
|
||||
crossPanelState.hoverInsertionIndex !== undefined
|
||||
) {
|
||||
const dropAction = crossPanelState.draggingGroup ? groupDropAction : crossPanelDropAction;
|
||||
dropAction?.(panelId, crossPanelState.hoverTargetPanelId, crossPanelState.hoverInsertionIndex);
|
||||
}
|
||||
// Within-panel reorder
|
||||
else if (insertionIndex !== undefined) {
|
||||
const oldIndex = dragStartState.tabIndex;
|
||||
|
||||
// Adjust for the fact that removing the dragged tab shifts indices
|
||||
let newIndex = insertionIndex;
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
reorderAction?.(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endDrag();
|
||||
}
|
||||
|
||||
function dragAbort(e: MouseEvent | KeyboardEvent) {
|
||||
if (e instanceof MouseEvent && e.button === BUTTON_RIGHT) endDrag();
|
||||
if (e instanceof KeyboardEvent && e.key === "Escape") endDrag();
|
||||
}
|
||||
|
||||
function dragScroll() {
|
||||
if (dragging && insertionIndex !== undefined) {
|
||||
calculateInsertionIndex(lastPointerX);
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragStartState = undefined;
|
||||
dragging = false;
|
||||
insertionIndex = undefined;
|
||||
insertionMarkerLeft = undefined;
|
||||
endCrossPanelDrag();
|
||||
removeDragListeners();
|
||||
}
|
||||
|
||||
function pointerIsInsideTabBar(e: PointerEvent): boolean {
|
||||
const groupDiv = tabGroupElement?.div?.();
|
||||
if (!groupDiv) return false;
|
||||
|
||||
const rect = groupDiv.getBoundingClientRect();
|
||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
/// Detect which zone the pointer is in: the nearest edge (by diagonal quadrant) if within the 25% border, or center if interior.
|
||||
function detectDockingEdge(clientX: number, clientY: number, rect: DOMRect): DockingEdge {
|
||||
const distLeft = clientX - rect.left;
|
||||
const distRight = rect.right - clientX;
|
||||
const distTop = clientY - rect.top;
|
||||
const distBottom = rect.bottom - clientY;
|
||||
const minDist = Math.min(distLeft, distRight, distTop, distBottom);
|
||||
|
||||
// If the nearest edge is beyond the 25% threshold, it's the center zone
|
||||
const THRESHOLD = 0.25;
|
||||
const edgeThresholdX = rect.width * THRESHOLD;
|
||||
const edgeThresholdY = rect.height * THRESHOLD;
|
||||
if (distLeft > edgeThresholdX && distRight > edgeThresholdX && distTop > edgeThresholdY && distBottom > edgeThresholdY) return "Center";
|
||||
|
||||
// Return whichever edge is closest (diagonal dividing lines between quadrants)
|
||||
if (minDist === distLeft) return "Left";
|
||||
if (minDist === distRight) return "Right";
|
||||
if (minDist === distTop) return "Top";
|
||||
return "Bottom";
|
||||
}
|
||||
|
||||
// Calculate the insertion position for a foreign panel's tab bar
|
||||
function calculateForeignInsertionIndex(pointerX: number, targetPanelId: string, tabBarDiv: HTMLDivElement) {
|
||||
const tabBarRect = tabBarDiv.getBoundingClientRect();
|
||||
const tabs = tabBarDiv.querySelectorAll(":scope > [data-tab]");
|
||||
let bestIndex = 0;
|
||||
let bestMarkerLeft = 0;
|
||||
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const tabRect = tabs[i].getBoundingClientRect();
|
||||
const tabCenter = tabRect.left + tabRect.width / 2;
|
||||
|
||||
if (pointerX > tabCenter) {
|
||||
bestIndex = i + 1;
|
||||
bestMarkerLeft = tabRect.right - tabBarRect.left;
|
||||
} else {
|
||||
bestMarkerLeft = tabRect.left - tabBarRect.left;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Must be at least 2px from the left so its left half doesn't get cut off along the left of the tab bar
|
||||
updateCrossPanelHover(targetPanelId, bestIndex, Math.max(2, bestMarkerLeft));
|
||||
}
|
||||
|
||||
function calculateInsertionIndex(pointerX: number) {
|
||||
const groupDiv = tabGroupElement?.div?.();
|
||||
if (!dragStartState || !groupDiv) return;
|
||||
|
||||
const groupRect = groupDiv.getBoundingClientRect();
|
||||
let bestIndex = 0;
|
||||
let bestMarkerLeft = 0;
|
||||
|
||||
// Walk through each tab to find the insertion point closest to the pointer
|
||||
for (let i = 0; i < tabLabels.length; i++) {
|
||||
const tabDiv = tabElements[i]?.div?.();
|
||||
if (!tabDiv) continue;
|
||||
|
||||
const tabRect = tabDiv.getBoundingClientRect();
|
||||
const tabMidpoint = tabRect.left + tabRect.width / 2;
|
||||
|
||||
if (pointerX > tabMidpoint) {
|
||||
bestIndex = i + 1;
|
||||
bestMarkerLeft = tabRect.right - groupRect.left;
|
||||
} else {
|
||||
bestIndex = i;
|
||||
bestMarkerLeft = tabRect.left - groupRect.left;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
insertionIndex = bestIndex;
|
||||
insertionMarkerLeft = Math.max(2, bestMarkerLeft);
|
||||
}
|
||||
|
||||
function addDragListeners() {
|
||||
document.addEventListener("pointermove", dragPointerMove);
|
||||
document.addEventListener("pointerup", dragPointerUp);
|
||||
document.addEventListener("mousedown", dragAbort);
|
||||
document.addEventListener("keydown", dragAbort);
|
||||
tabGroupElement?.div?.()?.addEventListener("scroll", dragScroll);
|
||||
}
|
||||
|
||||
function removeDragListeners() {
|
||||
document.removeEventListener("pointermove", dragPointerMove);
|
||||
document.removeEventListener("pointerup", dragPointerUp);
|
||||
document.removeEventListener("mousedown", dragAbort);
|
||||
document.removeEventListener("keydown", dragAbort);
|
||||
tabGroupElement?.div?.()?.removeEventListener("scroll", dragScroll);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol
|
||||
on:pointerdown={() => panelTypes[tabActiveIndex] && editor.setActivePanel(panelTypes[tabActiveIndex])}
|
||||
class={`panel ${className}`.trim()}
|
||||
{classes}
|
||||
style={styleName}
|
||||
{styles}
|
||||
data-panel-body={panelId}
|
||||
data-panel-dockable={crossPanelDropAction ? "" : undefined}
|
||||
>
|
||||
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
||||
<LayoutRow
|
||||
class="tab-group"
|
||||
scrollableX={true}
|
||||
data-panel-tab-bar={crossPanelDropAction ? panelId : undefined}
|
||||
on:pointerdown={tabBarPointerDown}
|
||||
on:click={onEmptySpaceAction}
|
||||
on:auxclick={onEmptySpaceAction}
|
||||
bind:this={tabGroupElement}
|
||||
>
|
||||
{#each tabLabels as tabLabel, tabIndex}
|
||||
<LayoutRow
|
||||
class="tab"
|
||||
classes={{ active: tabIndex === tabActiveIndex }}
|
||||
data-tab
|
||||
tooltipLabel={tabLabel.tooltipLabel}
|
||||
tooltipDescription={tabLabel.tooltipDescription}
|
||||
on:pointerdown={(e) => tabPointerDown(e, tabIndex)}
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
on:auxclick={(e) => {
|
||||
// Middle mouse button click
|
||||
if (e.button === BUTTON_MIDDLE) {
|
||||
e.stopPropagation();
|
||||
closeAction?.(tabIndex);
|
||||
}
|
||||
}}
|
||||
bind:this={tabElements[tabIndex]}
|
||||
>
|
||||
<LayoutRow class="name">
|
||||
{#if editingNameTabIndex !== tabIndex}
|
||||
<TextLabel class="text" on:dblclick={() => renameAction && setEditingTabName(tabIndex, tabLabel.name)}>{tabLabel.name}</TextLabel>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
bind:this={editingNameInputElement}
|
||||
bind:value={editingNameText}
|
||||
on:pointerdown|stopPropagation
|
||||
on:dblclick|stopPropagation
|
||||
on:blur={commitEditingTabName}
|
||||
on:keydown={(e) => {
|
||||
// Stop propagation when we handle the key ourselves so the global keyboard forwarder doesn't dispatch them and trigger unrelated bindings
|
||||
if (e.key === "Enter") {
|
||||
commitEditingTabName(e);
|
||||
e.stopPropagation();
|
||||
} else if (e.key === "Escape") {
|
||||
editingNameTabIndex = undefined;
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if tabLabel.unsaved}
|
||||
<TextLabel classes={{ hidden: editingNameTabIndex === tabIndex }}>*</TextLabel>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{#if tabCloseButtons}
|
||||
<IconButton
|
||||
action={(e) => {
|
||||
e?.stopPropagation();
|
||||
closeAction?.(tabIndex);
|
||||
}}
|
||||
icon="CloseX"
|
||||
size={16}
|
||||
data-close-button
|
||||
/>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
{#if dragging && insertionMarkerLeft !== undefined}
|
||||
<div class="tab-insertion-mark" style:left={`${insertionMarkerLeft}px`}></div>
|
||||
{/if}
|
||||
{#if !dragging && crossPanelDropAction && $panelDrag.active && $panelDrag.hoverTargetPanelId === panelId && $panelDrag.hoverInsertionMarkerLeft !== undefined}
|
||||
<div class="tab-insertion-mark" style:left={`${$panelDrag.hoverInsertionMarkerLeft}px`}></div>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
<LayoutCol class="panel-body">
|
||||
{#if panelTypes[tabActiveIndex]}
|
||||
<svelte:component this={PANEL_COMPONENTS[panelTypes[tabActiveIndex]]} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
{#if $panelDrag.active && $panelDrag.hoverDockingPanelId === panelId && $panelDrag.hoverDockingEdge}
|
||||
<div
|
||||
class="docking-ghost"
|
||||
class:left={$panelDrag.hoverDockingEdge === "Left"}
|
||||
class:right={$panelDrag.hoverDockingEdge === "Right"}
|
||||
class:top={$panelDrag.hoverDockingEdge === "Top"}
|
||||
class:bottom={$panelDrag.hoverDockingEdge === "Bottom"}
|
||||
class:center={$panelDrag.hoverDockingEdge === "Center"}
|
||||
></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.panel {
|
||||
background: var(--color-1-nearblack);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.tab-bar {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
min-height: auto;
|
||||
background: var(--color-1-nearblack); // Needed for the viewport hole punch on desktop
|
||||
flex-shrink: 0;
|
||||
|
||||
&.min-widths .tab-group .tab {
|
||||
min-width: 120px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.tab-group {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
|
||||
// This always hangs out at the end of the last tab, providing 16px (15px plus the 1px reserved for the separator line) to the right of the tabs.
|
||||
// When the last tab is selected, its bottom rounded fillet adds 16px to the width, which stretches the scrollbar width allocation in only that situation.
|
||||
// This pseudo-element ensures we always reserve that space to prevent the scrollbar from jumping when the last tab is selected.
|
||||
// There is unfortunately no apparent way to remove that 16px gap from the end of the scroll container, since negative margin does not reduce the scrollbar allocation.
|
||||
&::after {
|
||||
content: "";
|
||||
width: 15px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 0 1 auto;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
background: var(--color-3-darkgray);
|
||||
border-radius: 6px 6px 0 0;
|
||||
position: relative;
|
||||
|
||||
&:not(:first-child)::before,
|
||||
&::after {
|
||||
content: "";
|
||||
width: 16px;
|
||||
height: 8px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child)::before {
|
||||
left: -16px;
|
||||
border-bottom-right-radius: 8px;
|
||||
box-shadow: 8px 0 0 0 var(--color-3-darkgray);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: -16px;
|
||||
border-bottom-left-radius: 8px;
|
||||
box-shadow: -8px 0 0 0 var(--color-3-darkgray);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1 1 100%;
|
||||
|
||||
.text-label {
|
||||
// Height and line-height required because https://stackoverflow.com/a/21611191/775283
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.text {
|
||||
overflow-x: hidden;
|
||||
white-space: pre;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
color: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
margin: 0 -4px;
|
||||
padding: 0 4px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-1-nearblack);
|
||||
field-sizing: content;
|
||||
align-self: center;
|
||||
// Stack above the absolutely-positioned close button so it doesn't intercept clicks at the input's right edge.
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.text-label.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
& + .tab {
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
&:not(.active) + .tab:not(.active)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--color-5-dullgray);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 1px;
|
||||
|
||||
&:not(.active)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--color-5-dullgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.tab-insertion-mark) .tab .icon-button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-insertion-mark {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
margin-left: -2px;
|
||||
z-index: 1;
|
||||
background: var(--color-e-nearwhite);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
background: var(--color-3-darkgray);
|
||||
flex: 1 1 100%;
|
||||
flex-direction: column;
|
||||
|
||||
> div {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.docking-ghost) .tab-bar,
|
||||
&:has(.docking-ghost) .panel-body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.docking-ghost {
|
||||
position: absolute;
|
||||
background: rgba(var(--color-f-white-rgb), 0.2);
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition:
|
||||
top 0.2s ease,
|
||||
left 0.2s ease,
|
||||
width 0.2s ease,
|
||||
height 0.2s ease;
|
||||
|
||||
&.left {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.right {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.top {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&.center {
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: calc(100% - 12px);
|
||||
height: calc(100% - 12px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Needed for the viewport hole punch on desktop
|
||||
.viewport-hole-punch .panel.document-panel,
|
||||
.viewport-hole-punch .panel.document-panel .panel-body:not(:has(.welcome-panel)) {
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import Panel from "/src/components/window/Panel.svelte";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import type { DockingSplitDirection, DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const MIN_PANEL_SIZE = 100;
|
||||
const DOUBLE_CLICK_MILLISECONDS = 500;
|
||||
// Must match DOCUMENT_PANEL_SHARE / NON_DOCUMENT_PANEL_SHARE in utility_types.rs
|
||||
const DOCUMENT_PANEL_SHARE = 0.8;
|
||||
const EQUAL_PANEL_SHARE = 0.5;
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
|
||||
export let subdivision: PanelLayoutSubdivision | undefined;
|
||||
export let depth: number;
|
||||
export let splitPath: number[] = [];
|
||||
|
||||
// Local size overrides for gutter resizing (keyed by child index)
|
||||
let sizeOverrides: Record<number, number> = {};
|
||||
// Gutter resize state
|
||||
let gutterResizeRestore: [number, number] | undefined = undefined;
|
||||
let pointerCaptureId: number | undefined = undefined;
|
||||
let activeResizeCleanup: (() => void) | undefined = undefined;
|
||||
let lastGutterClickTarget: EventTarget | undefined = undefined;
|
||||
let lastGutterClickTime = 0;
|
||||
let lastSubdivisionRef: PanelLayoutSubdivision | undefined = undefined;
|
||||
|
||||
// At even depths (0, 2, 4...) children are in a row, at odd depths (1, 3, 5...) in a column
|
||||
$: horizontal = depth % 2 === 0;
|
||||
// Compare by reference because `safe_not_equal` treats any store update as changed, which would wipe drag overrides
|
||||
$: if (subdivision !== lastSubdivisionRef) {
|
||||
sizeOverrides = {};
|
||||
lastSubdivisionRef = subdivision;
|
||||
}
|
||||
// Reactive array of resolved sizes (merging backend defaults with local overrides)
|
||||
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
|
||||
$: documentTabLabels = $portfolio.documents.map((doc: DocumentInfo) => {
|
||||
const name = doc.name;
|
||||
const unsaved = !doc.is_saved;
|
||||
if (!editor.inDevelopmentMode()) return { name, unsaved };
|
||||
|
||||
const tooltipDescription = `Document ID: ${doc.id}`;
|
||||
return { name, unsaved, tooltipLabel: name, tooltipDescription };
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
activeResizeCleanup?.();
|
||||
});
|
||||
|
||||
function resizePanel(e: PointerEvent, prevIndex: number, nextIndex: number) {
|
||||
if (!(subdivision && "Split" in subdivision)) return;
|
||||
|
||||
const gutter = e.target;
|
||||
if (!(gutter instanceof HTMLDivElement)) return;
|
||||
|
||||
const nextSibling = gutter.nextElementSibling;
|
||||
const prevSibling = gutter.previousElementSibling;
|
||||
const parentElement = gutter.parentElement;
|
||||
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return;
|
||||
|
||||
// Double-click resets the two adjacent panels to the default ratio (80:20 near document, otherwise 50:50)
|
||||
const now = Date.now();
|
||||
const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter;
|
||||
|
||||
lastGutterClickTime = now;
|
||||
lastGutterClickTarget = gutter;
|
||||
|
||||
if (isDoubleClick) {
|
||||
const children = subdivision.Split.children;
|
||||
const adjacentSum = resolvedSizes[prevIndex] + resolvedSizes[nextIndex];
|
||||
|
||||
const prevHasDocument = subtreeContainsDocument(children[prevIndex].subdivision);
|
||||
const nextHasDocument = subtreeContainsDocument(children[nextIndex].subdivision);
|
||||
|
||||
let prevShare = EQUAL_PANEL_SHARE;
|
||||
if (prevHasDocument && !nextHasDocument) prevShare = DOCUMENT_PANEL_SHARE;
|
||||
else if (!prevHasDocument && nextHasDocument) prevShare = 1 - DOCUMENT_PANEL_SHARE;
|
||||
|
||||
sizeOverrides[prevIndex] = adjacentSum * prevShare;
|
||||
sizeOverrides[nextIndex] = adjacentSum * (1 - prevShare);
|
||||
sizeOverrides = sizeOverrides;
|
||||
|
||||
const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size);
|
||||
editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes));
|
||||
return;
|
||||
}
|
||||
|
||||
const isHorizontal = horizontal;
|
||||
|
||||
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
|
||||
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
|
||||
|
||||
// Only redistribute within the two adjacent panels' combined flex-grow total
|
||||
const adjacentFlexGrowTotal = resolvedSizes[prevIndex] + resolvedSizes[nextIndex];
|
||||
const adjacentPixelTotal = prevSiblingSize + nextSiblingSize;
|
||||
|
||||
pointerCaptureId = e.pointerId;
|
||||
gutter.setPointerCapture(pointerCaptureId);
|
||||
|
||||
const mouseStart = isHorizontal ? e.clientX : e.clientY;
|
||||
|
||||
const abortResize = () => {
|
||||
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
||||
pointerCaptureId = undefined;
|
||||
removeListeners();
|
||||
activeResizeCleanup = undefined;
|
||||
|
||||
if (gutterResizeRestore !== undefined) {
|
||||
sizeOverrides[nextIndex] = gutterResizeRestore[0];
|
||||
sizeOverrides[prevIndex] = gutterResizeRestore[1];
|
||||
sizeOverrides = sizeOverrides;
|
||||
gutterResizeRestore = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
|
||||
let mouseDelta = mouseStart - mouseCurrent;
|
||||
|
||||
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
|
||||
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
|
||||
|
||||
if (gutterResizeRestore === undefined) gutterResizeRestore = [resolvedSizes[nextIndex], resolvedSizes[prevIndex]];
|
||||
|
||||
sizeOverrides[nextIndex] = (adjacentFlexGrowTotal * (nextSiblingSize + mouseDelta)) / adjacentPixelTotal;
|
||||
sizeOverrides[prevIndex] = (adjacentFlexGrowTotal * (prevSiblingSize - mouseDelta)) / adjacentPixelTotal;
|
||||
sizeOverrides = sizeOverrides;
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
gutterResizeRestore = undefined;
|
||||
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
||||
removeListeners();
|
||||
activeResizeCleanup = undefined;
|
||||
|
||||
// Persist the resized sizes to the backend
|
||||
if ("Split" in subdivision) {
|
||||
const allSizes = subdivision.Split.children.map((child, i) => sizeOverrides[i] ?? child.size);
|
||||
editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes));
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const BUTTONS_RIGHT = 0b0000_0010;
|
||||
if (e.buttons & BUTTONS_RIGHT) abortResize();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") abortResize();
|
||||
};
|
||||
|
||||
const addListeners = () => {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
};
|
||||
|
||||
const removeListeners = () => {
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
|
||||
addListeners();
|
||||
activeResizeCleanup = removeListeners;
|
||||
}
|
||||
|
||||
function crossPanelDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) {
|
||||
editor.movePanelTab(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
|
||||
}
|
||||
|
||||
function groupDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) {
|
||||
editor.moveAllPanelTabs(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
|
||||
}
|
||||
|
||||
function splitDrop(targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) {
|
||||
editor.splitPanelGroup(BigInt(targetPanelId), direction, tabs, activeTabIndex);
|
||||
}
|
||||
|
||||
function isDocumentGroup(state: PanelGroupState): boolean {
|
||||
return state.tabs.some((t) => t === "Document" || t === "Welcome");
|
||||
}
|
||||
|
||||
function subtreeContainsDocument(node: PanelLayoutSubdivision): boolean {
|
||||
if ("PanelGroup" in node) return isDocumentGroup(node.PanelGroup.state);
|
||||
if ("Split" in node) return node.Split.children.some((child) => subtreeContainsDocument(child.subdivision));
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if subdivision && "PanelGroup" in subdivision}
|
||||
{@const group = subdivision.PanelGroup}
|
||||
{#if isDocumentGroup(group.state)}
|
||||
<Panel
|
||||
class="document-panel"
|
||||
panelId={String(group.id)}
|
||||
panelTypes={$portfolio.documents.length > 0 ? $portfolio.documents.map(() => "Document") : ["Welcome"]}
|
||||
tabCloseButtons={true}
|
||||
tabMinWidths={true}
|
||||
tabLabels={documentTabLabels}
|
||||
emptySpaceAction={() => editor.newDocumentDialog()}
|
||||
clickAction={(tabIndex) => editor.selectDocument($portfolio.documents[tabIndex].id)}
|
||||
closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
||||
reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)}
|
||||
renameAction={(tabIndex, newName) => {
|
||||
// Ensure the target document is the active one before renaming, since `RenameDocument` operates on the active document
|
||||
editor.selectDocument($portfolio.documents[tabIndex].id);
|
||||
editor.renameDocument(newName);
|
||||
}}
|
||||
tabActiveIndex={$portfolio.activeDocumentIndex}
|
||||
groupDropAction={groupDrop}
|
||||
splitDropAction={splitDrop}
|
||||
/>
|
||||
{:else}
|
||||
<Panel
|
||||
panelId={String(group.id)}
|
||||
panelTypes={group.state.tabs}
|
||||
tabLabels={group.state.tabs.map((name) => ({ name }))}
|
||||
tabActiveIndex={Number(group.state.active_tab_index)}
|
||||
clickAction={(tabIndex) => editor.setPanelGroupActiveTab(group.id, tabIndex)}
|
||||
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab(group.id, oldIndex, newIndex)}
|
||||
crossPanelDropAction={crossPanelDrop}
|
||||
groupDropAction={groupDrop}
|
||||
splitDropAction={splitDrop}
|
||||
/>
|
||||
{/if}
|
||||
{:else if subdivision && "Split" in subdivision}
|
||||
{#each subdivision.Split.children as child, index}
|
||||
{#if index > 0}
|
||||
{#if horizontal}
|
||||
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e, index - 1, index)} />
|
||||
{:else}
|
||||
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e, index - 1, index)} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if horizontal}
|
||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
|
||||
<svelte:self subdivision={child.subdivision} depth={depth + 1} splitPath={[...splitPath, index]} />
|
||||
</LayoutCol>
|
||||
{:else}
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
|
||||
<svelte:self subdivision={child.subdivision} depth={depth + 1} splitPath={[...splitPath, index]} />
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.workspace-grid-resize-gutter {
|
||||
flex: 0 0 4px;
|
||||
border-radius: 2px;
|
||||
transition: background 0.2s 0s;
|
||||
|
||||
&.layout-row {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
&.layout-col {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-5-dullgray);
|
||||
transition: background 0.2s 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-grid-subdivision {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-height: 28px;
|
||||
|
||||
&.folded {
|
||||
flex-grow: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Needed for the viewport hole punch on desktop
|
||||
.viewport-hole-punch .workspace-grid-subdivision:has(> .panel.document-panel)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 6px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
|
||||
|
||||
let statusBarHintsLayout: Layout = [];
|
||||
let statusBarInfoLayout: Layout = [];
|
||||
|
||||
onMount(() => {
|
||||
subscriptions.subscribeLayoutUpdate("StatusBarHints", (data) => {
|
||||
patchLayout(statusBarHintsLayout, data);
|
||||
statusBarHintsLayout = statusBarHintsLayout;
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("StatusBarInfo", (data) => {
|
||||
patchLayout(statusBarInfoLayout, data);
|
||||
statusBarInfoLayout = statusBarInfoLayout;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
subscriptions.unsubscribeLayoutUpdate("StatusBarHints");
|
||||
subscriptions.unsubscribeLayoutUpdate("StatusBarInfo");
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutRow class="status-bar">
|
||||
<WidgetLayout class="hints" layout={statusBarHintsLayout} layoutTarget="StatusBarHints" />
|
||||
<Separator />
|
||||
<WidgetLayout class="info" layout={statusBarInfoLayout} layoutTarget="StatusBarInfo" />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.status-bar {
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
|
||||
.hints {
|
||||
overflow: hidden;
|
||||
--row-height: 24px;
|
||||
margin: 0 4px;
|
||||
max-width: calc(100% - 2 * 4px);
|
||||
flex: 1 1 auto;
|
||||
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.text-label,
|
||||
.shortcut-label {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
+ .text-label,
|
||||
+ .shortcut-label {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label:not(.bold) + .shortcut-label {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.text-label.bold {
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.hints + .separator {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -40px;
|
||||
width: 40px;
|
||||
background: linear-gradient(to right, rgba(var(--color-2-mildblack-rgb), 0) 0%, rgba(var(--color-2-mildblack-rgb), 1) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
margin: 0 4px;
|
||||
--row-height: 24px;
|
||||
justify-content: flex-end;
|
||||
|
||||
.text-label {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
+ .text-label {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { AppWindowStore } from "/src/stores/app-window";
|
||||
import { enterFullscreen, exitFullscreen } from "/src/stores/fullscreen";
|
||||
import type { FullscreenStore } from "/src/stores/fullscreen";
|
||||
import type { TooltipStore } from "/src/stores/tooltip";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { EditorWrapper, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const keyboardLockApiSupported = navigator.keyboard !== undefined && "lock" in navigator.keyboard;
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
|
||||
const appWindow = getContext<AppWindowStore>("appWindow");
|
||||
const fullscreen = getContext<FullscreenStore>("fullscreen");
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
|
||||
let menuBarLayout: Layout = [];
|
||||
|
||||
$: showFullscreenButton = $appWindow.platform === "Web" || $fullscreen.windowFullscreen || (import.meta.env.MODE === "native" && $appWindow.fullscreen);
|
||||
$: isFullscreen = import.meta.env.MODE === "native" ? $appWindow.fullscreen : $fullscreen.windowFullscreen;
|
||||
// On Mac, the menu bar height needs to be scaled by the inverse of the UI scale to fit its native window buttons
|
||||
$: height = $appWindow.platform === "Mac" ? 28 * (1 / $appWindow.uiScale) : 28;
|
||||
|
||||
onMount(() => {
|
||||
subscriptions.subscribeLayoutUpdate("MenuBar", (data) => {
|
||||
patchLayout(menuBarLayout, data);
|
||||
menuBarLayout = menuBarLayout;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
subscriptions.unsubscribeLayoutUpdate("MenuBar");
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutRow class="title-bar" styles={{ "--title-bar-height": height + "px" }}>
|
||||
<!-- Menu bar -->
|
||||
<LayoutRow class="menu-bar">
|
||||
{#if $appWindow.platform !== "Mac"}
|
||||
<WidgetLayout layout={menuBarLayout} layoutTarget="MenuBar" />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
<!-- Window frame -->
|
||||
<LayoutRow class="window-frame" on:mousedown={() => !isFullscreen && editor.appWindowDrag()} on:dblclick={() => !isFullscreen && editor.appWindowMaximize()} />
|
||||
<!-- Window buttons -->
|
||||
<LayoutRow class="window-buttons" classes={{ fullscreen: showFullscreenButton, windows: $appWindow.platform === "Windows", linux: $appWindow.platform === "Linux" }}>
|
||||
{#if $appWindow.platform !== "Mac"}
|
||||
{#if showFullscreenButton}
|
||||
<LayoutRow
|
||||
tooltipLabel={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||
tooltipDescription={$appWindow.platform === "Web" && keyboardLockApiSupported
|
||||
? "While fullscreen, keyboard shortcuts normally reserved by the browser become available."
|
||||
: undefined}
|
||||
tooltipShortcut={$tooltip.fullscreenShortcut}
|
||||
on:click={() => {
|
||||
if (import.meta.env.MODE === "native") editor.appWindowFullscreen();
|
||||
else ($fullscreen.windowFullscreen ? exitFullscreen : enterFullscreen)();
|
||||
}}
|
||||
>
|
||||
<IconLabel icon={isFullscreen ? "FullscreenExit" : "FullscreenEnter"} />
|
||||
</LayoutRow>
|
||||
{:else}
|
||||
<LayoutRow tooltipLabel="Minimize" on:click={() => editor.appWindowMinimize()}>
|
||||
<IconLabel icon="WindowButtonWinMinimize" />
|
||||
</LayoutRow>
|
||||
<LayoutRow tooltipLabel={$appWindow.maximized ? ($appWindow.platform === "Windows" ? "Restore Down" : "Unmaximize") : "Maximize"} on:click={() => editor.appWindowMaximize()}>
|
||||
<IconLabel icon={$appWindow.maximized ? "WindowButtonWinRestoreDown" : "WindowButtonWinMaximize"} />
|
||||
</LayoutRow>
|
||||
<LayoutRow tooltipLabel="Close" on:click={() => editor.appWindowClose()}>
|
||||
<IconLabel icon="WindowButtonWinClose" />
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.title-bar {
|
||||
flex: 0 0 auto;
|
||||
height: var(--height);
|
||||
--height: var(--title-bar-height);
|
||||
|
||||
// Frameless PWA drag regions and left/right offsets for window controls, see:
|
||||
// https://web.dev/articles/window-controls-overlay
|
||||
@media not (display-mode: fullscreen) {
|
||||
--height: env(titlebar-area-height, var(--title-bar-height));
|
||||
|
||||
> .layout-row {
|
||||
&.window-frame {
|
||||
-webkit-app-region: drag;
|
||||
// app-region: drag; // TODO: Uncomment this when SCSS doesn't consider it an unknown property, which produces a warning that CI treats as a failure
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: env(titlebar-area-x, 0);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: calc(100% - env(titlebar-area-width, 100%) - env(titlebar-area-x, 0px));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .layout-row {
|
||||
flex: 0 0 auto;
|
||||
|
||||
> .widget-span {
|
||||
--row-height: var(--height);
|
||||
|
||||
> * {
|
||||
--widget-height: var(--height);
|
||||
}
|
||||
}
|
||||
|
||||
&.window-frame {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.window-buttons {
|
||||
> .layout-row {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.fullscreen > .layout-row {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
&.windows:not(.fullscreen) > .layout-row {
|
||||
padding: 0 17px;
|
||||
|
||||
&:hover {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
&:last-of-type:hover {
|
||||
background: #c42b1c;
|
||||
}
|
||||
}
|
||||
|
||||
&.linux:not(.fullscreen) > .layout-row {
|
||||
padding: 0 12px;
|
||||
|
||||
&:hover {
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpadding
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
||||
|
||||
// Graphite's custom properties added to the global `window` object
|
||||
interface Window {
|
||||
imageCanvases: Record<string, HTMLCanvasElement>;
|
||||
receiveNativeMessage?: (buffer: ArrayBuffer) => void;
|
||||
}
|
||||
|
||||
// Build-time constant injected by the `wasmSplitting` plugin in `vite.config.ts`
|
||||
declare const __WASM_PART_COUNT__: number;
|
||||
|
||||
// Graphite's custom "pointerlockmove" event dispatched by input.ts for pointer lock in the CEF desktop app
|
||||
interface WindowEventMap {
|
||||
pointerlockmove: CustomEvent<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
// Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Keyboard
|
||||
interface Navigator {
|
||||
keyboard?: Keyboard;
|
||||
}
|
||||
interface Keyboard {
|
||||
lock(keyCodes?: string[]): Promise<void>;
|
||||
unlock(): void;
|
||||
getLayoutMap(): Promise<KeyboardLayoutMap>;
|
||||
}
|
||||
interface KeyboardLayoutMap {
|
||||
entries(): IterableIterator<[string, string]>;
|
||||
get(key: string): string | undefined;
|
||||
has(key: string): boolean;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
// Experimental EyeDropper API: https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper
|
||||
interface Window {
|
||||
EyeDropper?: typeof EyeDropper;
|
||||
}
|
||||
declare class EyeDropper {
|
||||
constructor();
|
||||
open(options?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }>;
|
||||
}
|
||||
|
||||
// Experimental "clipboard-read" Permission: https://developer.mozilla.org/en-US/docs/Web/API/Permissions
|
||||
interface Permissions {
|
||||
query(permissionDesc: { name: "clipboard-read" }): Promise<PermissionStatus>;
|
||||
}
|
||||
|
||||
// Non-standard Stack Trace Limit API: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/stackTraceLimit
|
||||
interface ErrorConstructor {
|
||||
stackTraceLimit?: number;
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// Graphics
|
||||
import GraphiteLogotypeSolid from "/../branding/assets/graphics/graphite-logotype-solid.svg";
|
||||
|
||||
const GRAPHICS = {
|
||||
GraphiteLogotypeSolid: { svg: GraphiteLogotypeSolid, size: undefined },
|
||||
} as const;
|
||||
|
||||
// 12px Solid
|
||||
import Add from "/../branding/assets/icon-12px-solid/add.svg";
|
||||
import Checkmark from "/../branding/assets/icon-12px-solid/checkmark.svg";
|
||||
import Clipped from "/../branding/assets/icon-12px-solid/clipped.svg";
|
||||
import CloseX from "/../branding/assets/icon-12px-solid/close-x.svg";
|
||||
import Delay from "/../branding/assets/icon-12px-solid/delay.svg";
|
||||
import DotThick from "/../branding/assets/icon-12px-solid/dot-thick.svg";
|
||||
import Dot from "/../branding/assets/icon-12px-solid/dot.svg";
|
||||
import DropdownArrow from "/../branding/assets/icon-12px-solid/dropdown-arrow.svg";
|
||||
import Edit12px from "/../branding/assets/icon-12px-solid/edit-12px.svg";
|
||||
import Empty12px from "/../branding/assets/icon-12px-solid/empty-12px.svg";
|
||||
import Failure from "/../branding/assets/icon-12px-solid/failure.svg";
|
||||
import FullscreenEnter from "/../branding/assets/icon-12px-solid/fullscreen-enter.svg";
|
||||
import FullscreenExit from "/../branding/assets/icon-12px-solid/fullscreen-exit.svg";
|
||||
import GridDotted from "/../branding/assets/icon-12px-solid/grid-dotted.svg";
|
||||
import Grid from "/../branding/assets/icon-12px-solid/grid.svg";
|
||||
import Info from "/../branding/assets/icon-12px-solid/info.svg";
|
||||
import KeyboardArrowDown from "/../branding/assets/icon-12px-solid/keyboard-arrow-down.svg";
|
||||
import KeyboardArrowLeft from "/../branding/assets/icon-12px-solid/keyboard-arrow-left.svg";
|
||||
import KeyboardArrowRight from "/../branding/assets/icon-12px-solid/keyboard-arrow-right.svg";
|
||||
import KeyboardArrowUp from "/../branding/assets/icon-12px-solid/keyboard-arrow-up.svg";
|
||||
import KeyboardBackspace from "/../branding/assets/icon-12px-solid/keyboard-backspace.svg";
|
||||
import KeyboardCommand from "/../branding/assets/icon-12px-solid/keyboard-command.svg";
|
||||
import KeyboardControl from "/../branding/assets/icon-12px-solid/keyboard-control.svg";
|
||||
import KeyboardEnter from "/../branding/assets/icon-12px-solid/keyboard-enter.svg";
|
||||
import KeyboardOption from "/../branding/assets/icon-12px-solid/keyboard-option.svg";
|
||||
import KeyboardShift from "/../branding/assets/icon-12px-solid/keyboard-shift.svg";
|
||||
import KeyboardSpace from "/../branding/assets/icon-12px-solid/keyboard-space.svg";
|
||||
import KeyboardTab from "/../branding/assets/icon-12px-solid/keyboard-tab.svg";
|
||||
import License12px from "/../branding/assets/icon-12px-solid/license-12px.svg";
|
||||
import Link from "/../branding/assets/icon-12px-solid/link.svg";
|
||||
import Overlays from "/../branding/assets/icon-12px-solid/overlays.svg";
|
||||
import Remove from "/../branding/assets/icon-12px-solid/remove.svg";
|
||||
import RenderModeNormal from "/../branding/assets/icon-12px-solid/render-mode-normal.svg";
|
||||
import RenderModeOutline from "/../branding/assets/icon-12px-solid/render-mode-outline.svg";
|
||||
import RenderModePixels from "/../branding/assets/icon-12px-solid/render-mode-pixels.svg";
|
||||
import RenderModeSvg from "/../branding/assets/icon-12px-solid/render-mode-svg.svg";
|
||||
import Snapping from "/../branding/assets/icon-12px-solid/snapping.svg";
|
||||
import SwapHorizontal from "/../branding/assets/icon-12px-solid/swap-horizontal.svg";
|
||||
import SwapVertical from "/../branding/assets/icon-12px-solid/swap-vertical.svg";
|
||||
import VerticalEllipsis from "/../branding/assets/icon-12px-solid/vertical-ellipsis.svg";
|
||||
import Warning from "/../branding/assets/icon-12px-solid/warning.svg";
|
||||
import WindowButtonWinClose from "/../branding/assets/icon-12px-solid/window-button-win-close.svg";
|
||||
import WindowButtonWinMaximize from "/../branding/assets/icon-12px-solid/window-button-win-maximize.svg";
|
||||
import WindowButtonWinMinimize from "/../branding/assets/icon-12px-solid/window-button-win-minimize.svg";
|
||||
import WindowButtonWinRestoreDown from "/../branding/assets/icon-12px-solid/window-button-win-restore-down.svg";
|
||||
import WorkingColors from "/../branding/assets/icon-12px-solid/working-colors.svg";
|
||||
|
||||
const SOLID_12PX = {
|
||||
Add: { svg: Add, size: 12 },
|
||||
Checkmark: { svg: Checkmark, size: 12 },
|
||||
Clipped: { svg: Clipped, size: 12 },
|
||||
CloseX: { svg: CloseX, size: 12 },
|
||||
Delay: { svg: Delay, size: 12 },
|
||||
Dot: { svg: Dot, size: 12 },
|
||||
DotThick: { svg: DotThick, size: 12 },
|
||||
DropdownArrow: { svg: DropdownArrow, size: 12 },
|
||||
Edit12px: { svg: Edit12px, size: 12 },
|
||||
Empty12px: { svg: Empty12px, size: 12 },
|
||||
Failure: { svg: Failure, size: 12 },
|
||||
FullscreenEnter: { svg: FullscreenEnter, size: 12 },
|
||||
FullscreenExit: { svg: FullscreenExit, size: 12 },
|
||||
Grid: { svg: Grid, size: 12 },
|
||||
GridDotted: { svg: GridDotted, size: 12 },
|
||||
Info: { svg: Info, size: 12 },
|
||||
KeyboardArrowDown: { svg: KeyboardArrowDown, size: 12 },
|
||||
KeyboardArrowLeft: { svg: KeyboardArrowLeft, size: 12 },
|
||||
KeyboardArrowRight: { svg: KeyboardArrowRight, size: 12 },
|
||||
KeyboardArrowUp: { svg: KeyboardArrowUp, size: 12 },
|
||||
KeyboardBackspace: { svg: KeyboardBackspace, size: 12 },
|
||||
KeyboardCommand: { svg: KeyboardCommand, size: 12 },
|
||||
KeyboardControl: { svg: KeyboardControl, size: 12 },
|
||||
KeyboardEnter: { svg: KeyboardEnter, size: 12 },
|
||||
KeyboardOption: { svg: KeyboardOption, size: 12 },
|
||||
KeyboardShift: { svg: KeyboardShift, size: 12 },
|
||||
KeyboardSpace: { svg: KeyboardSpace, size: 12 },
|
||||
KeyboardTab: { svg: KeyboardTab, size: 12 },
|
||||
License12px: { svg: License12px, size: 12 },
|
||||
Link: { svg: Link, size: 12 },
|
||||
Overlays: { svg: Overlays, size: 12 },
|
||||
Remove: { svg: Remove, size: 12 },
|
||||
RenderModeNormal: { svg: RenderModeNormal, size: 12 },
|
||||
RenderModeOutline: { svg: RenderModeOutline, size: 12 },
|
||||
RenderModePixels: { svg: RenderModePixels, size: 12 },
|
||||
RenderModeSvg: { svg: RenderModeSvg, size: 12 },
|
||||
Snapping: { svg: Snapping, size: 12 },
|
||||
SwapHorizontal: { svg: SwapHorizontal, size: 12 },
|
||||
SwapVertical: { svg: SwapVertical, size: 12 },
|
||||
VerticalEllipsis: { svg: VerticalEllipsis, size: 12 },
|
||||
Warning: { svg: Warning, size: 12 },
|
||||
WindowButtonWinClose: { svg: WindowButtonWinClose, size: 12 },
|
||||
WindowButtonWinMaximize: { svg: WindowButtonWinMaximize, size: 12 },
|
||||
WindowButtonWinMinimize: { svg: WindowButtonWinMinimize, size: 12 },
|
||||
WindowButtonWinRestoreDown: { svg: WindowButtonWinRestoreDown, size: 12 },
|
||||
WorkingColors: { svg: WorkingColors, size: 12 },
|
||||
} as const;
|
||||
|
||||
// 16px Solid
|
||||
import AlignBottom from "/../branding/assets/icon-16px-solid/align-bottom.svg";
|
||||
import AlignHorizontalCenter from "/../branding/assets/icon-16px-solid/align-horizontal-center.svg";
|
||||
import AlignLeft from "/../branding/assets/icon-16px-solid/align-left.svg";
|
||||
import AlignRight from "/../branding/assets/icon-16px-solid/align-right.svg";
|
||||
import AlignTop from "/../branding/assets/icon-16px-solid/align-top.svg";
|
||||
import AlignVerticalCenter from "/../branding/assets/icon-16px-solid/align-vertical-center.svg";
|
||||
import Artboard from "/../branding/assets/icon-16px-solid/artboard.svg";
|
||||
import BooleanDifference from "/../branding/assets/icon-16px-solid/boolean-difference.svg";
|
||||
import BooleanDivide from "/../branding/assets/icon-16px-solid/boolean-divide.svg";
|
||||
import BooleanIntersect from "/../branding/assets/icon-16px-solid/boolean-intersect.svg";
|
||||
import BooleanSubtractBack from "/../branding/assets/icon-16px-solid/boolean-subtract-back.svg";
|
||||
import BooleanSubtractFront from "/../branding/assets/icon-16px-solid/boolean-subtract-front.svg";
|
||||
import BooleanUnion from "/../branding/assets/icon-16px-solid/boolean-union.svg";
|
||||
import Bug from "/../branding/assets/icon-16px-solid/bug.svg";
|
||||
import CheckboxChecked from "/../branding/assets/icon-16px-solid/checkbox-checked.svg";
|
||||
import CheckboxUnchecked from "/../branding/assets/icon-16px-solid/checkbox-unchecked.svg";
|
||||
import CloseAll from "/../branding/assets/icon-16px-solid/close-all.svg";
|
||||
import Close from "/../branding/assets/icon-16px-solid/close.svg";
|
||||
import Code from "/../branding/assets/icon-16px-solid/code.svg";
|
||||
import Copy from "/../branding/assets/icon-16px-solid/copy.svg";
|
||||
import Credits from "/../branding/assets/icon-16px-solid/credits.svg";
|
||||
import CustomColor from "/../branding/assets/icon-16px-solid/custom-color.svg";
|
||||
import Cut from "/../branding/assets/icon-16px-solid/cut.svg";
|
||||
import DeselectAll from "/../branding/assets/icon-16px-solid/deselect-all.svg";
|
||||
import Edit from "/../branding/assets/icon-16px-solid/edit.svg";
|
||||
import Empty from "/../branding/assets/icon-16px-solid/empty.svg";
|
||||
import ExpandFillStroke from "/../branding/assets/icon-16px-solid/expand-fill-stroke.svg";
|
||||
import EyeHidden from "/../branding/assets/icon-16px-solid/eye-hidden.svg";
|
||||
import EyeHide from "/../branding/assets/icon-16px-solid/eye-hide.svg";
|
||||
import EyeShow from "/../branding/assets/icon-16px-solid/eye-show.svg";
|
||||
import EyeVisible from "/../branding/assets/icon-16px-solid/eye-visible.svg";
|
||||
import Eyedropper from "/../branding/assets/icon-16px-solid/eyedropper.svg";
|
||||
import FileExport from "/../branding/assets/icon-16px-solid/file-export.svg";
|
||||
import FileImport from "/../branding/assets/icon-16px-solid/file-import.svg";
|
||||
import File from "/../branding/assets/icon-16px-solid/file.svg";
|
||||
import FlipHorizontal from "/../branding/assets/icon-16px-solid/flip-horizontal.svg";
|
||||
import FlipVertical from "/../branding/assets/icon-16px-solid/flip-vertical.svg";
|
||||
import FolderOpen from "/../branding/assets/icon-16px-solid/folder-open.svg";
|
||||
import Folder from "/../branding/assets/icon-16px-solid/folder.svg";
|
||||
import FrameAll from "/../branding/assets/icon-16px-solid/frame-all.svg";
|
||||
import FrameSelected from "/../branding/assets/icon-16px-solid/frame-selected.svg";
|
||||
import GraphViewClosed from "/../branding/assets/icon-16px-solid/graph-view-closed.svg";
|
||||
import GraphViewOpen from "/../branding/assets/icon-16px-solid/graph-view-open.svg";
|
||||
import GraphiteLogo from "/../branding/assets/icon-16px-solid/graphite-logo.svg";
|
||||
import HandleVisibilityAll from "/../branding/assets/icon-16px-solid/handle-visibility-all.svg";
|
||||
import HandleVisibilityFrontier from "/../branding/assets/icon-16px-solid/handle-visibility-frontier.svg";
|
||||
import HandleVisibilitySelected from "/../branding/assets/icon-16px-solid/handle-visibility-selected.svg";
|
||||
import Heart from "/../branding/assets/icon-16px-solid/heart.svg";
|
||||
import HistoryRedo from "/../branding/assets/icon-16px-solid/history-redo.svg";
|
||||
import HistoryUndo from "/../branding/assets/icon-16px-solid/history-undo.svg";
|
||||
import IconsGrid from "/../branding/assets/icon-16px-solid/icons-grid.svg";
|
||||
import Image from "/../branding/assets/icon-16px-solid/image.svg";
|
||||
import InterpolationBlend from "/../branding/assets/icon-16px-solid/interpolation-blend.svg";
|
||||
import InterpolationMorph from "/../branding/assets/icon-16px-solid/interpolation-morph.svg";
|
||||
import Layer from "/../branding/assets/icon-16px-solid/layer.svg";
|
||||
import License from "/../branding/assets/icon-16px-solid/license.svg";
|
||||
import NewLayer from "/../branding/assets/icon-16px-solid/new-layer.svg";
|
||||
import NodeBlur from "/../branding/assets/icon-16px-solid/node-blur.svg";
|
||||
import NodeBrushwork from "/../branding/assets/icon-16px-solid/node-brushwork.svg";
|
||||
import NodeColorCorrection from "/../branding/assets/icon-16px-solid/node-color-correction.svg";
|
||||
import NodeGradient from "/../branding/assets/icon-16px-solid/node-gradient.svg";
|
||||
import NodeMagicWand from "/../branding/assets/icon-16px-solid/node-magic-wand.svg";
|
||||
import NodeMask from "/../branding/assets/icon-16px-solid/node-mask.svg";
|
||||
import NodeMotionBlur from "/../branding/assets/icon-16px-solid/node-motion-blur.svg";
|
||||
import NodeNodes from "/../branding/assets/icon-16px-solid/node-nodes.svg";
|
||||
import NodeOutput from "/../branding/assets/icon-16px-solid/node-output.svg";
|
||||
import NodeShape from "/../branding/assets/icon-16px-solid/node-shape.svg";
|
||||
import NodeText from "/../branding/assets/icon-16px-solid/node-text.svg";
|
||||
import NodeTransform from "/../branding/assets/icon-16px-solid/node-transform.svg";
|
||||
import Node from "/../branding/assets/icon-16px-solid/node.svg";
|
||||
import PadlockLocked from "/../branding/assets/icon-16px-solid/padlock-locked.svg";
|
||||
import PadlockUnlocked from "/../branding/assets/icon-16px-solid/padlock-unlocked.svg";
|
||||
import Paste from "/../branding/assets/icon-16px-solid/paste.svg";
|
||||
import PinActive from "/../branding/assets/icon-16px-solid/pin-active.svg";
|
||||
import PinInactive from "/../branding/assets/icon-16px-solid/pin-inactive.svg";
|
||||
import PlaybackPause from "/../branding/assets/icon-16px-solid/playback-pause.svg";
|
||||
import PlaybackPlay from "/../branding/assets/icon-16px-solid/playback-play.svg";
|
||||
import PlaybackToEnd from "/../branding/assets/icon-16px-solid/playback-to-end.svg";
|
||||
import PlaybackToStart from "/../branding/assets/icon-16px-solid/playback-to-start.svg";
|
||||
import Random from "/../branding/assets/icon-16px-solid/random.svg";
|
||||
import Reload from "/../branding/assets/icon-16px-solid/reload.svg";
|
||||
import Reset from "/../branding/assets/icon-16px-solid/reset.svg";
|
||||
import Resync from "/../branding/assets/icon-16px-solid/resync.svg";
|
||||
import ReverseRadialGradientToLeft from "/../branding/assets/icon-16px-solid/reverse-radial-gradient-to-left.svg";
|
||||
import ReverseRadialGradientToRight from "/../branding/assets/icon-16px-solid/reverse-radial-gradient-to-right.svg";
|
||||
import Reverse from "/../branding/assets/icon-16px-solid/reverse.svg";
|
||||
import Save from "/../branding/assets/icon-16px-solid/save.svg";
|
||||
import SelectAll from "/../branding/assets/icon-16px-solid/select-all.svg";
|
||||
import SelectParent from "/../branding/assets/icon-16px-solid/select-parent.svg";
|
||||
import Settings from "/../branding/assets/icon-16px-solid/settings.svg";
|
||||
import SmallDot from "/../branding/assets/icon-16px-solid/small-dot.svg";
|
||||
import StackBottom from "/../branding/assets/icon-16px-solid/stack-bottom.svg";
|
||||
import StackHollow from "/../branding/assets/icon-16px-solid/stack-hollow.svg";
|
||||
import StackLower from "/../branding/assets/icon-16px-solid/stack-lower.svg";
|
||||
import StackRaise from "/../branding/assets/icon-16px-solid/stack-raise.svg";
|
||||
import StackReverse from "/../branding/assets/icon-16px-solid/stack-reverse.svg";
|
||||
import Stack from "/../branding/assets/icon-16px-solid/stack.svg";
|
||||
import StrokeAlignCenter from "/../branding/assets/icon-16px-solid/stroke-align-center.svg";
|
||||
import StrokeAlignInside from "/../branding/assets/icon-16px-solid/stroke-align-inside.svg";
|
||||
import StrokeAlignOutside from "/../branding/assets/icon-16px-solid/stroke-align-outside.svg";
|
||||
import StrokeCapButt from "/../branding/assets/icon-16px-solid/stroke-cap-butt.svg";
|
||||
import StrokeCapRound from "/../branding/assets/icon-16px-solid/stroke-cap-round.svg";
|
||||
import StrokeCapSquare from "/../branding/assets/icon-16px-solid/stroke-cap-square.svg";
|
||||
import StrokeJoinBevel from "/../branding/assets/icon-16px-solid/stroke-join-bevel.svg";
|
||||
import StrokeJoinMiter from "/../branding/assets/icon-16px-solid/stroke-join-miter.svg";
|
||||
import StrokeJoinRound from "/../branding/assets/icon-16px-solid/stroke-join-round.svg";
|
||||
import StrokeOrderAbove from "/../branding/assets/icon-16px-solid/stroke-order-above.svg";
|
||||
import StrokeOrderBelow from "/../branding/assets/icon-16px-solid/stroke-order-below.svg";
|
||||
import TextAlignCenter from "/../branding/assets/icon-16px-solid/text-align-center.svg";
|
||||
import TextAlignLeft from "/../branding/assets/icon-16px-solid/text-align-left.svg";
|
||||
import TextAlignRight from "/../branding/assets/icon-16px-solid/text-align-right.svg";
|
||||
import TextAlignSpineAway from "/../branding/assets/icon-16px-solid/text-align-spine-away.svg";
|
||||
import TextAlignSpineTowards from "/../branding/assets/icon-16px-solid/text-align-spine-towards.svg";
|
||||
import TextJustifyAll from "/../branding/assets/icon-16px-solid/text-justify-all.svg";
|
||||
import TextJustifyCenter from "/../branding/assets/icon-16px-solid/text-justify-center.svg";
|
||||
import TextJustifyLeft from "/../branding/assets/icon-16px-solid/text-justify-left.svg";
|
||||
import TextJustifyRight from "/../branding/assets/icon-16px-solid/text-justify-right.svg";
|
||||
import TiltReset from "/../branding/assets/icon-16px-solid/tilt-reset.svg";
|
||||
import Tilt from "/../branding/assets/icon-16px-solid/tilt.svg";
|
||||
import TransformationGrab from "/../branding/assets/icon-16px-solid/transformation-grab.svg";
|
||||
import TransformationRotate from "/../branding/assets/icon-16px-solid/transformation-rotate.svg";
|
||||
import TransformationScale from "/../branding/assets/icon-16px-solid/transformation-scale.svg";
|
||||
import Trash from "/../branding/assets/icon-16px-solid/trash.svg";
|
||||
import TurnNegative90 from "/../branding/assets/icon-16px-solid/turn-negative-90.svg";
|
||||
import TurnPositive90 from "/../branding/assets/icon-16px-solid/turn-positive-90.svg";
|
||||
import UserManual from "/../branding/assets/icon-16px-solid/user-manual.svg";
|
||||
import ViewportDesignMode from "/../branding/assets/icon-16px-solid/viewport-design-mode.svg";
|
||||
import ViewportGuideMode from "/../branding/assets/icon-16px-solid/viewport-guide-mode.svg";
|
||||
import ViewportSelectMode from "/../branding/assets/icon-16px-solid/viewport-select-mode.svg";
|
||||
import Volunteer from "/../branding/assets/icon-16px-solid/volunteer.svg";
|
||||
import Website from "/../branding/assets/icon-16px-solid/website.svg";
|
||||
import WorkingColorsPrimary from "/../branding/assets/icon-16px-solid/working-colors-primary.svg";
|
||||
import WorkingColorsSecondary from "/../branding/assets/icon-16px-solid/working-colors-secondary.svg";
|
||||
import Zoom1x from "/../branding/assets/icon-16px-solid/zoom-1x.svg";
|
||||
import Zoom2x from "/../branding/assets/icon-16px-solid/zoom-2x.svg";
|
||||
import ZoomIn from "/../branding/assets/icon-16px-solid/zoom-in.svg";
|
||||
import ZoomOut from "/../branding/assets/icon-16px-solid/zoom-out.svg";
|
||||
import ZoomReset from "/../branding/assets/icon-16px-solid/zoom-reset.svg";
|
||||
|
||||
const SOLID_16PX = {
|
||||
AlignBottom: { svg: AlignBottom, size: 16 },
|
||||
AlignHorizontalCenter: { svg: AlignHorizontalCenter, size: 16 },
|
||||
AlignLeft: { svg: AlignLeft, size: 16 },
|
||||
AlignRight: { svg: AlignRight, size: 16 },
|
||||
AlignTop: { svg: AlignTop, size: 16 },
|
||||
AlignVerticalCenter: { svg: AlignVerticalCenter, size: 16 },
|
||||
Artboard: { svg: Artboard, size: 16 },
|
||||
BooleanDifference: { svg: BooleanDifference, size: 16 },
|
||||
BooleanDivide: { svg: BooleanDivide, size: 16 },
|
||||
BooleanIntersect: { svg: BooleanIntersect, size: 16 },
|
||||
BooleanSubtractBack: { svg: BooleanSubtractBack, size: 16 },
|
||||
BooleanSubtractFront: { svg: BooleanSubtractFront, size: 16 },
|
||||
BooleanUnion: { svg: BooleanUnion, size: 16 },
|
||||
Bug: { svg: Bug, size: 16 },
|
||||
CheckboxChecked: { svg: CheckboxChecked, size: 16 },
|
||||
CheckboxUnchecked: { svg: CheckboxUnchecked, size: 16 },
|
||||
Close: { svg: Close, size: 16 },
|
||||
CloseAll: { svg: CloseAll, size: 16 },
|
||||
Code: { svg: Code, size: 16 },
|
||||
Copy: { svg: Copy, size: 16 },
|
||||
Credits: { svg: Credits, size: 16 },
|
||||
CustomColor: { svg: CustomColor, size: 16 },
|
||||
Cut: { svg: Cut, size: 16 },
|
||||
DeselectAll: { svg: DeselectAll, size: 16 },
|
||||
Edit: { svg: Edit, size: 16 },
|
||||
Empty: { svg: Empty, size: 16 },
|
||||
ExpandFillStroke: { svg: ExpandFillStroke, size: 16 },
|
||||
Eyedropper: { svg: Eyedropper, size: 16 },
|
||||
EyeHidden: { svg: EyeHidden, size: 16 },
|
||||
EyeHide: { svg: EyeHide, size: 16 },
|
||||
EyeShow: { svg: EyeShow, size: 16 },
|
||||
EyeVisible: { svg: EyeVisible, size: 16 },
|
||||
File: { svg: File, size: 16 },
|
||||
FileExport: { svg: FileExport, size: 16 },
|
||||
FileImport: { svg: FileImport, size: 16 },
|
||||
FlipHorizontal: { svg: FlipHorizontal, size: 16 },
|
||||
FlipVertical: { svg: FlipVertical, size: 16 },
|
||||
Folder: { svg: Folder, size: 16 },
|
||||
FolderOpen: { svg: FolderOpen, size: 16 },
|
||||
FrameAll: { svg: FrameAll, size: 16 },
|
||||
FrameSelected: { svg: FrameSelected, size: 16 },
|
||||
GraphiteLogo: { svg: GraphiteLogo, size: 16 },
|
||||
GraphViewClosed: { svg: GraphViewClosed, size: 16 },
|
||||
GraphViewOpen: { svg: GraphViewOpen, size: 16 },
|
||||
HandleVisibilityAll: { svg: HandleVisibilityAll, size: 16 },
|
||||
HandleVisibilityFrontier: { svg: HandleVisibilityFrontier, size: 16 },
|
||||
HandleVisibilitySelected: { svg: HandleVisibilitySelected, size: 16 },
|
||||
Heart: { svg: Heart, size: 16 },
|
||||
HistoryRedo: { svg: HistoryRedo, size: 16 },
|
||||
HistoryUndo: { svg: HistoryUndo, size: 16 },
|
||||
IconsGrid: { svg: IconsGrid, size: 16 },
|
||||
Image: { svg: Image, size: 16 },
|
||||
InterpolationBlend: { svg: InterpolationBlend, size: 16 },
|
||||
InterpolationMorph: { svg: InterpolationMorph, size: 16 },
|
||||
Layer: { svg: Layer, size: 16 },
|
||||
License: { svg: License, size: 16 },
|
||||
NewLayer: { svg: NewLayer, size: 16 },
|
||||
Node: { svg: Node, size: 16 },
|
||||
NodeBlur: { svg: NodeBlur, size: 16 },
|
||||
NodeBrushwork: { svg: NodeBrushwork, size: 16 },
|
||||
NodeColorCorrection: { svg: NodeColorCorrection, size: 16 },
|
||||
NodeGradient: { svg: NodeGradient, size: 16 },
|
||||
NodeMagicWand: { svg: NodeMagicWand, size: 16 },
|
||||
NodeMask: { svg: NodeMask, size: 16 },
|
||||
NodeMotionBlur: { svg: NodeMotionBlur, size: 16 },
|
||||
NodeNodes: { svg: NodeNodes, size: 16 },
|
||||
NodeOutput: { svg: NodeOutput, size: 16 },
|
||||
NodeShape: { svg: NodeShape, size: 16 },
|
||||
NodeText: { svg: NodeText, size: 16 },
|
||||
NodeTransform: { svg: NodeTransform, size: 16 },
|
||||
PadlockLocked: { svg: PadlockLocked, size: 16 },
|
||||
PadlockUnlocked: { svg: PadlockUnlocked, size: 16 },
|
||||
Paste: { svg: Paste, size: 16 },
|
||||
PinActive: { svg: PinActive, size: 16 },
|
||||
PinInactive: { svg: PinInactive, size: 16 },
|
||||
PlaybackPause: { svg: PlaybackPause, size: 16 },
|
||||
PlaybackPlay: { svg: PlaybackPlay, size: 16 },
|
||||
PlaybackToEnd: { svg: PlaybackToEnd, size: 16 },
|
||||
PlaybackToStart: { svg: PlaybackToStart, size: 16 },
|
||||
Random: { svg: Random, size: 16 },
|
||||
Reload: { svg: Reload, size: 16 },
|
||||
Reset: { svg: Reset, size: 16 },
|
||||
Resync: { svg: Resync, size: 16 },
|
||||
Reverse: { svg: Reverse, size: 16 },
|
||||
ReverseRadialGradientToLeft: { svg: ReverseRadialGradientToLeft, size: 16 },
|
||||
ReverseRadialGradientToRight: { svg: ReverseRadialGradientToRight, size: 16 },
|
||||
Save: { svg: Save, size: 16 },
|
||||
SelectAll: { svg: SelectAll, size: 16 },
|
||||
SelectParent: { svg: SelectParent, size: 16 },
|
||||
Settings: { svg: Settings, size: 16 },
|
||||
SmallDot: { svg: SmallDot, size: 16 },
|
||||
Stack: { svg: Stack, size: 16 },
|
||||
StackBottom: { svg: StackBottom, size: 16 },
|
||||
StackHollow: { svg: StackHollow, size: 16 },
|
||||
StackLower: { svg: StackLower, size: 16 },
|
||||
StackRaise: { svg: StackRaise, size: 16 },
|
||||
StackReverse: { svg: StackReverse, size: 16 },
|
||||
StrokeAlignCenter: { svg: StrokeAlignCenter, size: 16 },
|
||||
StrokeAlignInside: { svg: StrokeAlignInside, size: 16 },
|
||||
StrokeAlignOutside: { svg: StrokeAlignOutside, size: 16 },
|
||||
StrokeCapButt: { svg: StrokeCapButt, size: 16 },
|
||||
StrokeCapRound: { svg: StrokeCapRound, size: 16 },
|
||||
StrokeCapSquare: { svg: StrokeCapSquare, size: 16 },
|
||||
StrokeJoinBevel: { svg: StrokeJoinBevel, size: 16 },
|
||||
StrokeJoinMiter: { svg: StrokeJoinMiter, size: 16 },
|
||||
StrokeJoinRound: { svg: StrokeJoinRound, size: 16 },
|
||||
StrokeOrderAbove: { svg: StrokeOrderAbove, size: 16 },
|
||||
StrokeOrderBelow: { svg: StrokeOrderBelow, size: 16 },
|
||||
TextAlignCenter: { svg: TextAlignCenter, size: 16 },
|
||||
TextAlignLeft: { svg: TextAlignLeft, size: 16 },
|
||||
TextAlignRight: { svg: TextAlignRight, size: 16 },
|
||||
TextAlignSpineAway: { svg: TextAlignSpineAway, size: 16 },
|
||||
TextAlignSpineTowards: { svg: TextAlignSpineTowards, size: 16 },
|
||||
TextJustifyAll: { svg: TextJustifyAll, size: 16 },
|
||||
TextJustifyCenter: { svg: TextJustifyCenter, size: 16 },
|
||||
TextJustifyLeft: { svg: TextJustifyLeft, size: 16 },
|
||||
TextJustifyRight: { svg: TextJustifyRight, size: 16 },
|
||||
Tilt: { svg: Tilt, size: 16 },
|
||||
TiltReset: { svg: TiltReset, size: 16 },
|
||||
TransformationGrab: { svg: TransformationGrab, size: 16 },
|
||||
TransformationRotate: { svg: TransformationRotate, size: 16 },
|
||||
TransformationScale: { svg: TransformationScale, size: 16 },
|
||||
Trash: { svg: Trash, size: 16 },
|
||||
TurnNegative90: { svg: TurnNegative90, size: 16 },
|
||||
TurnPositive90: { svg: TurnPositive90, size: 16 },
|
||||
UserManual: { svg: UserManual, size: 16 },
|
||||
ViewportDesignMode: { svg: ViewportDesignMode, size: 16 },
|
||||
ViewportGuideMode: { svg: ViewportGuideMode, size: 16 },
|
||||
ViewportSelectMode: { svg: ViewportSelectMode, size: 16 },
|
||||
Volunteer: { svg: Volunteer, size: 16 },
|
||||
Website: { svg: Website, size: 16 },
|
||||
WorkingColorsPrimary: { svg: WorkingColorsPrimary, size: 16 },
|
||||
WorkingColorsSecondary: { svg: WorkingColorsSecondary, size: 16 },
|
||||
Zoom1x: { svg: Zoom1x, size: 16 },
|
||||
Zoom2x: { svg: Zoom2x, size: 16 },
|
||||
ZoomIn: { svg: ZoomIn, size: 16 },
|
||||
ZoomOut: { svg: ZoomOut, size: 16 },
|
||||
ZoomReset: { svg: ZoomReset, size: 16 },
|
||||
} as const;
|
||||
|
||||
// 16px Two-Tone
|
||||
import MouseHintDrag from "/../branding/assets/icon-16px-two-tone/mouse-hint-drag.svg";
|
||||
import MouseHintLmbDouble from "/../branding/assets/icon-16px-two-tone/mouse-hint-lmb-double.svg";
|
||||
import MouseHintLmbDrag from "/../branding/assets/icon-16px-two-tone/mouse-hint-lmb-drag.svg";
|
||||
import MouseHintLmb from "/../branding/assets/icon-16px-two-tone/mouse-hint-lmb.svg";
|
||||
import MouseHintMmbDrag from "/../branding/assets/icon-16px-two-tone/mouse-hint-mmb-drag.svg";
|
||||
import MouseHintMmb from "/../branding/assets/icon-16px-two-tone/mouse-hint-mmb.svg";
|
||||
import MouseHintNone from "/../branding/assets/icon-16px-two-tone/mouse-hint-none.svg";
|
||||
import MouseHintRmbDouble from "/../branding/assets/icon-16px-two-tone/mouse-hint-rmb-double.svg";
|
||||
import MouseHintRmbDrag from "/../branding/assets/icon-16px-two-tone/mouse-hint-rmb-drag.svg";
|
||||
import MouseHintRmb from "/../branding/assets/icon-16px-two-tone/mouse-hint-rmb.svg";
|
||||
import MouseHintScrollDown from "/../branding/assets/icon-16px-two-tone/mouse-hint-scroll-down.svg";
|
||||
import MouseHintScrollUp from "/../branding/assets/icon-16px-two-tone/mouse-hint-scroll-up.svg";
|
||||
|
||||
const TWO_TONE_16PX = {
|
||||
MouseHintDrag: { svg: MouseHintDrag, size: 16 },
|
||||
MouseHintLmb: { svg: MouseHintLmb, size: 16 },
|
||||
MouseHintLmbDouble: { svg: MouseHintLmbDouble, size: 16 },
|
||||
MouseHintLmbDrag: { svg: MouseHintLmbDrag, size: 16 },
|
||||
MouseHintMmb: { svg: MouseHintMmb, size: 16 },
|
||||
MouseHintMmbDrag: { svg: MouseHintMmbDrag, size: 16 },
|
||||
MouseHintNone: { svg: MouseHintNone, size: 16 },
|
||||
MouseHintRmb: { svg: MouseHintRmb, size: 16 },
|
||||
MouseHintRmbDouble: { svg: MouseHintRmbDouble, size: 16 },
|
||||
MouseHintRmbDrag: { svg: MouseHintRmbDrag, size: 16 },
|
||||
MouseHintScrollDown: { svg: MouseHintScrollDown, size: 16 },
|
||||
MouseHintScrollUp: { svg: MouseHintScrollUp, size: 16 },
|
||||
} as const;
|
||||
|
||||
// 24px Two-Tone
|
||||
import GeneralArtboardTool from "/../branding/assets/icon-24px-two-tone/general-artboard-tool.svg";
|
||||
import GeneralEyedropperTool from "/../branding/assets/icon-24px-two-tone/general-eyedropper-tool.svg";
|
||||
import GeneralFillTool from "/../branding/assets/icon-24px-two-tone/general-fill-tool.svg";
|
||||
import GeneralGradientTool from "/../branding/assets/icon-24px-two-tone/general-gradient-tool.svg";
|
||||
import GeneralNavigateTool from "/../branding/assets/icon-24px-two-tone/general-navigate-tool.svg";
|
||||
import GeneralSelectTool from "/../branding/assets/icon-24px-two-tone/general-select-tool.svg";
|
||||
import RasterBrushTool from "/../branding/assets/icon-24px-two-tone/raster-brush-tool.svg";
|
||||
import RasterCloneTool from "/../branding/assets/icon-24px-two-tone/raster-clone-tool.svg";
|
||||
import RasterDetailTool from "/../branding/assets/icon-24px-two-tone/raster-detail-tool.svg";
|
||||
import RasterHealTool from "/../branding/assets/icon-24px-two-tone/raster-heal-tool.svg";
|
||||
import RasterPatchTool from "/../branding/assets/icon-24px-two-tone/raster-patch-tool.svg";
|
||||
import RasterRelightTool from "/../branding/assets/icon-24px-two-tone/raster-relight-tool.svg";
|
||||
import VectorEllipseTool from "/../branding/assets/icon-24px-two-tone/vector-ellipse-tool.svg";
|
||||
import VectorFreehandTool from "/../branding/assets/icon-24px-two-tone/vector-freehand-tool.svg";
|
||||
import VectorLineTool from "/../branding/assets/icon-24px-two-tone/vector-line-tool.svg";
|
||||
import VectorPathTool from "/../branding/assets/icon-24px-two-tone/vector-path-tool.svg";
|
||||
import VectorPenTool from "/../branding/assets/icon-24px-two-tone/vector-pen-tool.svg";
|
||||
import VectorPolygonTool from "/../branding/assets/icon-24px-two-tone/vector-polygon-tool.svg";
|
||||
import VectorRectangleTool from "/../branding/assets/icon-24px-two-tone/vector-rectangle-tool.svg";
|
||||
import VectorSplineTool from "/../branding/assets/icon-24px-two-tone/vector-spline-tool.svg";
|
||||
import VectorTextTool from "/../branding/assets/icon-24px-two-tone/vector-text-tool.svg";
|
||||
|
||||
const TWO_TONE_24PX = {
|
||||
GeneralArtboardTool: { svg: GeneralArtboardTool, size: 24 },
|
||||
GeneralEyedropperTool: { svg: GeneralEyedropperTool, size: 24 },
|
||||
GeneralFillTool: { svg: GeneralFillTool, size: 24 },
|
||||
GeneralGradientTool: { svg: GeneralGradientTool, size: 24 },
|
||||
GeneralNavigateTool: { svg: GeneralNavigateTool, size: 24 },
|
||||
GeneralSelectTool: { svg: GeneralSelectTool, size: 24 },
|
||||
RasterBrushTool: { svg: RasterBrushTool, size: 24 },
|
||||
RasterCloneTool: { svg: RasterCloneTool, size: 24 },
|
||||
RasterDetailTool: { svg: RasterDetailTool, size: 24 },
|
||||
RasterHealTool: { svg: RasterHealTool, size: 24 },
|
||||
RasterPatchTool: { svg: RasterPatchTool, size: 24 },
|
||||
RasterRelightTool: { svg: RasterRelightTool, size: 24 },
|
||||
VectorEllipseTool: { svg: VectorEllipseTool, size: 24 },
|
||||
VectorFreehandTool: { svg: VectorFreehandTool, size: 24 },
|
||||
VectorLineTool: { svg: VectorLineTool, size: 24 },
|
||||
VectorPathTool: { svg: VectorPathTool, size: 24 },
|
||||
VectorPenTool: { svg: VectorPenTool, size: 24 },
|
||||
VectorRectangleTool: { svg: VectorRectangleTool, size: 24 },
|
||||
VectorPolygonTool: { svg: VectorPolygonTool, size: 24 },
|
||||
VectorSplineTool: { svg: VectorSplineTool, size: 24 },
|
||||
VectorTextTool: { svg: VectorTextTool, size: 24 },
|
||||
} as const;
|
||||
|
||||
// All icons
|
||||
const ICON_LIST = {
|
||||
...GRAPHICS,
|
||||
...SOLID_12PX,
|
||||
...SOLID_16PX,
|
||||
...TWO_TONE_16PX,
|
||||
...TWO_TONE_24PX,
|
||||
} as const;
|
||||
|
||||
// Exported icons and types
|
||||
export const ICONS: IconDefinitionType<typeof ICON_LIST> = ICON_LIST;
|
||||
export const ICON_SVG_STRINGS = Object.fromEntries(Object.entries(ICONS).map(([name, data]) => [name, data.svg]));
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
export type IconSize = undefined | 12 | 16 | 24 | 32;
|
||||
export type PopoverButtonStyle = "DropdownArrow" | "VerticalEllipsis";
|
||||
|
||||
// The following helper type declarations allow us to avoid manually maintaining the `IconName` type declaration as a string union paralleling the keys of the
|
||||
// icon definitions. It lets TypeScript do that for us. Our goal is to define the big key-value pair of icons by constraining its values, but inferring its keys.
|
||||
// Constraining its values means that TypeScript can make sure each icon definition has a valid size number from the union of numbers that is `IconSize`.
|
||||
// Inferring its keys means we don't have to specify a supertype like `string` or `any` for the key-value pair's keys, which would prevent us from accessing
|
||||
// the individual keys with `keyof typeof`. Absent a specified type for the keys, TypeScript falls back to inferring that the key-value pair's type is the
|
||||
// map of all its individual entries. Having the full list of entries lets us automatically set the `IconName` type to the union of strings that is the full
|
||||
// list of keys. The result is that we don't have to maintain a separate list of icon names since this scheme infers it from the keys of the icon definitions.
|
||||
// Based on https://stackoverflow.com/a/64119715/775283
|
||||
type IconDefinition = { svg: string; size: IconSize };
|
||||
type EvaluateType<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
type IconDefinitionType<T extends Record<string, IconDefinition>> = EvaluateType<{ [key in keyof T]: IconDefinition }>;
|
||||
@@ -0,0 +1,19 @@
|
||||
// This file is the browser's entry point for the JS bundle
|
||||
|
||||
import { mount, unmount } from "svelte";
|
||||
import App from "/src/App.svelte";
|
||||
import { registerServiceWorker } from "/src/utility-functions/service-worker";
|
||||
|
||||
// Register the service worker, except in dev mode and native (CEF) builds
|
||||
if (!import.meta.env.DEV && import.meta.env.MODE !== "native" && "serviceWorker" in navigator) {
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
document.body.setAttribute("data-app-container", "");
|
||||
|
||||
const app = mount(App, { target: document.body });
|
||||
|
||||
// Ensure the old component tree is properly torn down during HMR so all onDestroy hooks fire (which clean up IO managers, state providers, etc.)
|
||||
import.meta.hot?.dispose(() => unmount(app));
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { insertAtCaret, readAtCaret } from "/src/utility-functions/clipboard";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
let editorWrapper: EditorWrapper | undefined = undefined;
|
||||
|
||||
export function createClipboardManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
|
||||
destroyClipboardManager();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
editorWrapper = editor;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerClipboardWrite", (data) => {
|
||||
// If the Clipboard API is supported in the browser, copy text to the clipboard
|
||||
navigator.clipboard?.writeText?.(data.content);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerSelectionRead", async (data) => {
|
||||
editor.readSelection(readAtCaret(data.cut), data.cut);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerSelectionWrite", async (data) => {
|
||||
insertAtCaret(data.content);
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyClipboardManager() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite");
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
if (subscriptionsRouter && editorWrapper) newModule?.createClipboardManager(subscriptionsRouter, editorWrapper);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// Drag-toggle: the user presses one toggleable button and drags across its siblings to flip them all
|
||||
// to the opposite of the source's starting state.
|
||||
//
|
||||
// Peers declare themselves with two data attributes on the clickable element:
|
||||
// - data-drag-toggle-group="<group-name>": siblings share this name to form a drag-toggle group
|
||||
// - data-drag-toggle-state="<current-state>": the current toggle state (e.g. "visible" / "hidden")
|
||||
//
|
||||
// The gesture only engages once the pointer crosses from the source into a different sibling, so plain
|
||||
// clicks still toggle as usual. When engaged, the source is clicked once (toggling it) and any sibling
|
||||
// the pointer enters whose state still matches the source's recorded starting state is also clicked.
|
||||
|
||||
type ActiveGroupListener = (activeGroup: string | undefined) => void;
|
||||
|
||||
const listeners = new Set<ActiveGroupListener>();
|
||||
let activeGroup: string | undefined = undefined;
|
||||
let source: HTMLElement | undefined = undefined;
|
||||
let startingState: string | undefined = undefined;
|
||||
let visited = new WeakSet<Element>();
|
||||
let engaged = false;
|
||||
let suppressNextClickFromSource: HTMLElement | undefined = undefined;
|
||||
|
||||
export function createDragToggleManager(activeGroupListener?: ActiveGroupListener) {
|
||||
if (activeGroupListener) listeners.add(activeGroupListener);
|
||||
|
||||
// Install the window event listeners only when the first consumer subscribes
|
||||
if (listeners.size === 1) {
|
||||
// Capture phase on pointerdown preempts sibling drag handlers on ancestors so they don't also engage
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
// Capture phase on click suppresses the natural source-click before the button's handler runs
|
||||
window.addEventListener("click", onClickCapture, true);
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyDragToggleManager(activeGroupListener?: ActiveGroupListener) {
|
||||
if (activeGroupListener) listeners.delete(activeGroupListener);
|
||||
|
||||
// Uninstall the window event listeners only once the last consumer leaves
|
||||
if (listeners.size === 0) {
|
||||
window.removeEventListener("pointerdown", onPointerDown, true);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
window.removeEventListener("click", onClickCapture, true);
|
||||
|
||||
activeGroup = undefined;
|
||||
source = undefined;
|
||||
startingState = undefined;
|
||||
visited = new WeakSet();
|
||||
engaged = false;
|
||||
suppressNextClickFromSource = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyActiveGroupChange(group: string | undefined) {
|
||||
activeGroup = group;
|
||||
listeners.forEach((listener) => listener(group));
|
||||
}
|
||||
|
||||
function findMember(target: EventTarget | undefined): HTMLElement | undefined {
|
||||
if (!(target instanceof Element)) return undefined;
|
||||
const found = target.closest("[data-drag-toggle-group]");
|
||||
return found instanceof HTMLElement ? found : undefined;
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
suppressNextClickFromSource = undefined;
|
||||
|
||||
const found = findMember(e.target || undefined);
|
||||
if (!found) return;
|
||||
|
||||
// Stop the event so sibling drag/select handlers on ancestors don't also engage
|
||||
e.stopPropagation();
|
||||
|
||||
source = found;
|
||||
startingState = found.getAttribute("data-drag-toggle-state") || undefined;
|
||||
visited = new WeakSet();
|
||||
engaged = false;
|
||||
|
||||
notifyActiveGroupChange(found.getAttribute("data-drag-toggle-group") || undefined);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!activeGroup || !source) return;
|
||||
|
||||
const member = findMember(e.target || undefined);
|
||||
if (!member || member.getAttribute("data-drag-toggle-group") !== activeGroup) return;
|
||||
|
||||
// Engages only when the cursor crosses from the source to a different peer, so tiny wobbles over the source don't trigger
|
||||
if (member === source || visited.has(member)) return;
|
||||
|
||||
// First crossing engages the drag and toggles the source as part of the operation
|
||||
if (!engaged) {
|
||||
engaged = true;
|
||||
visited.add(source);
|
||||
if ((source.getAttribute("data-drag-toggle-state") || undefined) === startingState) source.click();
|
||||
}
|
||||
|
||||
// Toggle only peers still in the starting state, so we don't flip ones already at the target state
|
||||
if ((member.getAttribute("data-drag-toggle-state") || undefined) !== startingState) return;
|
||||
|
||||
visited.add(member);
|
||||
member.click();
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!activeGroup) return;
|
||||
|
||||
// If a drag engaged, the source was already clicked programmatically; suppress its natural click so it isn't re-toggled
|
||||
if (engaged && source) suppressNextClickFromSource = source;
|
||||
|
||||
source = undefined;
|
||||
startingState = undefined;
|
||||
visited = new WeakSet();
|
||||
engaged = false;
|
||||
|
||||
notifyActiveGroupChange(undefined);
|
||||
}
|
||||
|
||||
function onClickCapture(e: Event) {
|
||||
if (suppressNextClickFromSource && e.target instanceof Node && suppressNextClickFromSource.contains(e.target)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
suppressNextClickFromSource = undefined;
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
const carried = Array.from(listeners);
|
||||
carried.forEach((listener) => destroyDragToggleManager(listener));
|
||||
carried.forEach((listener) => newModule?.createDragToggleManager(listener));
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
export function createHyperlinkManager(subscriptions: SubscriptionsRouter) {
|
||||
destroyHyperlinkManager();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerVisitLink", async (data) => {
|
||||
window.open(data.url, "_blank", "noopener");
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyHyperlinkManager() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerVisitLink");
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
if (subscriptionsRouter) newModule?.createHyperlinkManager(subscriptionsRouter);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { DialogStore } from "/src/stores/dialog";
|
||||
import type { DocumentStore } from "/src/stores/document";
|
||||
import { fullscreenModeChanged } from "/src/stores/fullscreen";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { triggerClipboardRead } from "/src/utility-functions/clipboard";
|
||||
import {
|
||||
onBeforeUnload,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onPointerMove,
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
onMouseDown,
|
||||
onPotentialDoubleClick,
|
||||
onWheelScroll,
|
||||
onModifyInputField,
|
||||
onFocusOut,
|
||||
onContextMenu,
|
||||
onPaste,
|
||||
onPointerLockChange,
|
||||
} from "/src/utility-functions/input";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield" | "pointerlockchange" | "pointerlockerror";
|
||||
type EventListenerTarget = {
|
||||
addEventListener: typeof window.addEventListener;
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
};
|
||||
type Listener = { target: EventListenerTarget; eventName: EventName; action(event: Event): void; options?: AddEventListenerOptions };
|
||||
|
||||
export const PRESS_REPEAT_DELAY_MS = 400;
|
||||
export const PRESS_REPEAT_INTERVAL_MS = 72;
|
||||
export const PRESS_REPEAT_INTERVAL_RAPID_MS = 10;
|
||||
const listeners: Listener[] = [
|
||||
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => editorWrapper && portfolioStore && onBeforeUnload(e, editorWrapper, portfolioStore) },
|
||||
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => editorWrapper && dialogStore && onKeyUp(e, editorWrapper, dialogStore) },
|
||||
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => editorWrapper && dialogStore && onKeyDown(e, editorWrapper, dialogStore) },
|
||||
{ target: window, eventName: "pointermove", action: (e: PointerEvent) => editorWrapper && documentStore && onPointerMove(e, editorWrapper, documentStore) },
|
||||
{ target: window, eventName: "pointerdown", action: (e: PointerEvent) => editorWrapper && dialogStore && onPointerDown(e, editorWrapper, dialogStore) },
|
||||
{ target: window, eventName: "pointerup", action: (e: PointerEvent) => editorWrapper && onPointerUp(e, editorWrapper) },
|
||||
{ target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) },
|
||||
{ target: window, eventName: "mouseup", action: (e: MouseEvent) => editorWrapper && onPotentialDoubleClick(e, editorWrapper) },
|
||||
{ target: window, eventName: "wheel", action: (e: WheelEvent) => editorWrapper && onWheelScroll(e, editorWrapper), options: { passive: false } },
|
||||
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) },
|
||||
{ target: window, eventName: "focusout", action: () => onFocusOut() },
|
||||
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) },
|
||||
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() },
|
||||
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => editorWrapper && onPaste(e, editorWrapper) },
|
||||
{ target: window.document, eventName: "pointerlockchange", action: onPointerLockChange },
|
||||
{ target: window.document, eventName: "pointerlockerror", action: onPointerLockChange },
|
||||
];
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
let editorWrapper: EditorWrapper | undefined = undefined;
|
||||
let dialogStore: DialogStore | undefined = undefined;
|
||||
let portfolioStore: PortfolioStore | undefined = undefined;
|
||||
let documentStore: DocumentStore | undefined = undefined;
|
||||
|
||||
export function createInputManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper, dialog: DialogStore, portfolio: PortfolioStore, doc: DocumentStore) {
|
||||
destroyInputManager();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
editorWrapper = editor;
|
||||
dialogStore = dialog;
|
||||
portfolioStore = portfolio;
|
||||
documentStore = doc;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerClipboardRead", () => {
|
||||
triggerClipboardRead(editor);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("WindowPointerLockMove", (data) => {
|
||||
// Desktop app only: dispatch custom pointer lock movement events
|
||||
const event = new CustomEvent("pointerlockmove", { detail: { x: data.position[0], y: data.position[1] } });
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Add event bindings for the lifetime of the application
|
||||
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
|
||||
|
||||
// Focus the app container
|
||||
const app = window.document.querySelector("[data-app-container]");
|
||||
if (app instanceof HTMLElement) app.focus();
|
||||
}
|
||||
|
||||
// Return the destructor
|
||||
export function destroyInputManager() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerClipboardRead");
|
||||
subscriptions.unsubscribeFrontendMessage("WindowPointerLockMove");
|
||||
|
||||
// Remove event bindings after the lifetime of the application (or on hot-module replacement during development)
|
||||
listeners.forEach(({ target, eventName, action, options }) => target.removeEventListener(eventName, action, options));
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
if (subscriptionsRouter && editorWrapper && dialogStore && portfolioStore && documentStore)
|
||||
newModule?.createInputManager(subscriptionsRouter, editorWrapper, dialogStore, portfolioStore, documentStore);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { localizeTimestamp } from "/src/utility-functions/time";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
let editorWrapper: EditorWrapper | undefined = undefined;
|
||||
|
||||
export function createLocalizationManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
|
||||
destroyLocalizationManager();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
editorWrapper = editor;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate", (data) => {
|
||||
const localized = localizeTimestamp(data.commitDate);
|
||||
editor.requestAboutGraphiteDialogWithLocalizedCommitDate(localized.timestamp, localized.year);
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyLocalizationManager() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerAboutGraphiteLocalizedCommitDate");
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
if (subscriptionsRouter && editorWrapper) newModule?.createLocalizationManager(subscriptionsRouter, editorWrapper);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createCrashDialog } from "/src/stores/dialog";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
export function createPanicManager(subscriptions: SubscriptionsRouter) {
|
||||
destroyPanicManager();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("DisplayDialogPanic", (data) => {
|
||||
// `Error.stackTraceLimit` is only available in V8/Chromium
|
||||
const previousStackTraceLimit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = Infinity;
|
||||
const stackTrace = new Error().stack || "";
|
||||
Error.stackTraceLimit = previousStackTraceLimit;
|
||||
|
||||
const panicDetails = `${data.panicInfo}${stackTrace ? `\n\n${stackTrace}` : ""}`;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(panicDetails);
|
||||
|
||||
createCrashDialog(panicDetails);
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyPanicManager() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("DisplayDialogPanic");
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
if (subscriptionsRouter) newModule?.createPanicManager(subscriptionsRouter);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import {
|
||||
saveEditorPreferences,
|
||||
loadEditorPreferences,
|
||||
writePersistedState,
|
||||
readPersistedState,
|
||||
writePersistedDocument,
|
||||
readPersistedDocument,
|
||||
deletePersistedDocument,
|
||||
} from "/src/utility-functions/persistence";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
let editorWrapper: EditorWrapper | undefined = undefined;
|
||||
let portfolioStore: PortfolioStore | undefined = undefined;
|
||||
|
||||
export function createPersistenceManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper, portfolio: PortfolioStore) {
|
||||
destroyPersistenceManager();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
editorWrapper = editor;
|
||||
portfolioStore = portfolio;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerSavePreferences", async (data) => {
|
||||
await saveEditorPreferences(data.preferences);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerLoadPreferences", async () => {
|
||||
await loadEditorPreferences(editor);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteState", async (data) => {
|
||||
await writePersistedState(data.state);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerPersistenceReadState", async () => {
|
||||
await readPersistedState(editor);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteDocument", async (data) => {
|
||||
await writePersistedDocument(data);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerPersistenceReadDocument", async (data) => {
|
||||
await readPersistedDocument(data.documentId, editor);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerPersistenceDeleteDocument", async (data) => {
|
||||
await deletePersistedDocument(String(data.documentId));
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => {
|
||||
// TODO: Could be used to load documents from URL params or similar on launch
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyPersistenceManager() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSavePreferences");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerLoadPreferences");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteState");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceReadState");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceReadDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceDeleteDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerOpenLaunchDocuments");
|
||||
}
|
||||
|
||||
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||
import.meta.hot?.accept((newModule) => {
|
||||
if (subscriptionsRouter && editorWrapper && portfolioStore) newModule?.createPersistenceManager(subscriptionsRouter, editorWrapper, portfolioStore);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
// These placeholders are replaced in `vite.config.ts` at build time
|
||||
const PRECACHE_MANIFEST = self.__PRECACHE_MANIFEST;
|
||||
const DEFERRED_CACHE_MANIFEST = self.__DEFERRED_CACHE_MANIFEST;
|
||||
const SERVICE_WORKER_CONTENT_HASH = self.__SERVICE_WORKER_CONTENT_HASH;
|
||||
|
||||
const STATIC_CACHE_NAME = `static-${SERVICE_WORKER_CONTENT_HASH}`;
|
||||
const RUNTIME_ASSETS = "runtime-assets";
|
||||
const RUNTIME_FONTS = "runtime-fonts";
|
||||
|
||||
const FONT_LIST_API = "https://api.graphite.art/font-list";
|
||||
|
||||
// Build a set of precache URLs for quick lookup during fetch
|
||||
const PRECACHE_URLS = new Set(PRECACHE_MANIFEST.map((entry) => new URL(entry.url, self.location.origin).href));
|
||||
|
||||
// Track deferred manifest URLs and revisions for cache invalidation
|
||||
const DEFERRED_ENTRIES = new Map(DEFERRED_CACHE_MANIFEST.map((entry) => [new URL(entry.url, self.location.origin).href, entry.revision]));
|
||||
|
||||
// ==================
|
||||
// Caching strategies
|
||||
// ==================
|
||||
|
||||
function isCacheable(response) {
|
||||
// Cache normal successful responses and opaque responses (cross-origin no-cors, e.g. <link> stylesheets)
|
||||
return response.ok || response.type === "opaque";
|
||||
}
|
||||
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (isCacheable(response)) cache.put(request, response.clone());
|
||||
return response;
|
||||
}
|
||||
|
||||
async function networkFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (isCacheable(response)) cache.put(request, response.clone());
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
throw new Error(`Network request failed and no cache available for ${request.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ================
|
||||
// Lifecycle events
|
||||
// ================
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Precache app shell assets
|
||||
const cache = await caches.open(STATIC_CACHE_NAME);
|
||||
await Promise.all(
|
||||
PRECACHE_MANIFEST.map(async (entry) => {
|
||||
const response = await fetch(entry.url);
|
||||
if (!response.ok) throw new Error(`Precache fetch failed for ${entry.url}: ${response.status}`);
|
||||
// Strip the `redirected` flag which causes errors when served via respondWith
|
||||
const cleaned = response.redirected
|
||||
? new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
})
|
||||
: response;
|
||||
await cache.put(entry.url, cleaned);
|
||||
}),
|
||||
);
|
||||
|
||||
// Proactively cache the font catalog API
|
||||
try {
|
||||
const fontResponse = await fetch(FONT_LIST_API);
|
||||
if (fontResponse.ok) {
|
||||
const fontCache = await caches.open(RUNTIME_FONTS);
|
||||
await fontCache.put(FONT_LIST_API, fontResponse);
|
||||
}
|
||||
} catch {
|
||||
// Font catalog prefetch is best-effort, don't block installation
|
||||
}
|
||||
|
||||
await self.skipWaiting();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cacheNames = await caches.keys();
|
||||
|
||||
// Delete old precache versions
|
||||
const deletions = cacheNames.filter((name) => name.startsWith("static-") && name !== STATIC_CACHE_NAME).map((name) => caches.delete(name));
|
||||
await Promise.all(deletions);
|
||||
|
||||
// Prune stale deferred (demo artwork) entries
|
||||
const assetsCache = await caches.open(RUNTIME_ASSETS);
|
||||
const assetsKeys = await assetsCache.keys();
|
||||
await Promise.all(assetsKeys.filter((request) => !DEFERRED_ENTRIES.has(request.url)).map((request) => assetsCache.delete(request)));
|
||||
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// =============
|
||||
// Fetch routing
|
||||
// =============
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Pass through range requests (e.g. for large file streaming) and non-GET requests
|
||||
if (request.headers.has("range") || request.method !== "GET") return;
|
||||
|
||||
// Pre-cached assets (JS and Wasm bundle files, favicons, index.html)
|
||||
if (PRECACHE_URLS.has(url.href)) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE_NAME));
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred-cached assets (demo artwork, third-party licenses, etc.)
|
||||
if (DEFERRED_ENTRIES.has(url.href)) {
|
||||
event.respondWith(cacheFirst(request, RUNTIME_ASSETS));
|
||||
return;
|
||||
}
|
||||
|
||||
// Font catalog API: network-first to keep it fresh
|
||||
if (url.href.startsWith(FONT_LIST_API)) {
|
||||
event.respondWith(networkFirst(request, RUNTIME_FONTS));
|
||||
return;
|
||||
}
|
||||
|
||||
// Google Fonts CSS (font preview stylesheets): cache-first since responses are stable for a given query
|
||||
if (url.hostname === "fonts.googleapis.com") {
|
||||
event.respondWith(cacheFirst(request, RUNTIME_FONTS));
|
||||
return;
|
||||
}
|
||||
|
||||
// Google Fonts static files: cache-first since they are immutable CDN URLs
|
||||
if (url.hostname === "fonts.gstatic.com") {
|
||||
event.respondWith(cacheFirst(request, RUNTIME_FONTS));
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation requests: serve cached index.html for all routes (SPA pattern)
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(STATIC_CACHE_NAME);
|
||||
const cached = await cache.match("/index.html");
|
||||
return cached || fetch(request);
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else: network-only (no respondWith, let the browser handle it)
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Deferred caching via message
|
||||
// ============================
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "CACHE_DEFERRED") return;
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(RUNTIME_ASSETS);
|
||||
const fetchPromises = DEFERRED_CACHE_MANIFEST.map(async (entry) => {
|
||||
const fullUrl = new URL(entry.url, self.location.origin).href;
|
||||
|
||||
// Skip if already cached with the same revision
|
||||
const existing = await cache.match(fullUrl);
|
||||
if (existing?.headers.get("x-sw-revision") === entry.revision) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl);
|
||||
if (response.ok) {
|
||||
// Store the service worker revision in a custom header so we can check it on future installs
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("x-sw-revision", entry.revision);
|
||||
const taggedResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
||||
await cache.put(fullUrl, taggedResponse);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: skip files that fail to fetch
|
||||
}
|
||||
});
|
||||
await Promise.all(fetchPromises);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import type { AppWindowPlatform } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type AppWindowStore = ReturnType<typeof createAppWindowStore>;
|
||||
|
||||
type AppWindowStoreState = {
|
||||
platform: AppWindowPlatform;
|
||||
maximized: boolean;
|
||||
fullscreen: boolean;
|
||||
viewportHolePunch: boolean;
|
||||
uiScale: number;
|
||||
};
|
||||
const initialState: AppWindowStoreState = {
|
||||
platform: "Web",
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
viewportHolePunch: false,
|
||||
uiScale: 1,
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<AppWindowStoreState> = import.meta.hot?.data?.store || writable<AppWindowStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createAppWindowStore(subscriptions: SubscriptionsRouter) {
|
||||
destroyAppWindowStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdatePlatform", (data) => {
|
||||
update((state) => {
|
||||
state.platform = data.platform;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateMaximized", (data) => {
|
||||
update((state) => {
|
||||
state.maximized = data.maximized;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateFullscreen", (data) => {
|
||||
update((state) => {
|
||||
state.fullscreen = data.fullscreen;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateViewportHolePunch", (data) => {
|
||||
update((state) => {
|
||||
state.viewportHolePunch = data.active;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateUIScale", (data) => {
|
||||
update((state) => {
|
||||
state.uiScale = data.scale;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return { subscribe };
|
||||
}
|
||||
|
||||
export function destroyAppWindowStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("UpdatePlatform");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateMaximized");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateFullscreen");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateViewportHolePunch");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateUIScale");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { FillChoiceUI, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type ColorPickerCallbacks = {
|
||||
onColorChanged?: (value: FillChoiceUI) => void;
|
||||
onStartTransaction?: () => void;
|
||||
onCommitTransaction?: () => void;
|
||||
};
|
||||
|
||||
export type ColorPickerStoreState = {
|
||||
pickersAndGradient: Layout;
|
||||
details: Layout;
|
||||
callbacks: ColorPickerCallbacks;
|
||||
// True while the user is actively dragging one of the visual H/S/V/A pickers, so the popover knows to suppress its stray-pointer-close behavior until the drag ends.
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
const initialState: ColorPickerStoreState = {
|
||||
pickersAndGradient: [],
|
||||
details: [],
|
||||
callbacks: {},
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
// Persist the store across HMR so subscriptions stay live.
|
||||
const store: Writable<ColorPickerStoreState> = import.meta.hot?.data?.store || writable<ColorPickerStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export type ColorPickerStore = {
|
||||
subscribe: typeof subscribe;
|
||||
setCallbacks: (callbacks: ColorPickerCallbacks) => void;
|
||||
// Identity-checked so a hover-transfer race where another picker's `setCallbacks` already replaced these doesn't clobber the new picker's callbacks
|
||||
clearCallbacks: (expected: ColorPickerCallbacks) => void;
|
||||
setDragging: (dragging: boolean) => void;
|
||||
};
|
||||
|
||||
// The Rust handler keeps a single shared layout per target, but multiple `<ColorPicker />` Svelte instances may be mounted across
|
||||
// the app (one per `ColorInput`/`WorkingColorsInput`/etc.). Subscribing to the layout target from each instance is destructive,
|
||||
// only the last-registered callback wins. So we maintain a single global subscription here and let each `<ColorPicker />` instance
|
||||
// read from the resulting store and register its own per-open callbacks for color/transaction events.
|
||||
export function createColorPickerStore(subscriptions: SubscriptionsRouter): ColorPickerStore {
|
||||
destroyColorPickerStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("ColorPickerColorChanged", (data) => {
|
||||
update((state) => {
|
||||
state.callbacks.onColorChanged?.(data.value);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
subscriptions.subscribeFrontendMessage("ColorPickerStartHistoryTransaction", () => {
|
||||
update((state) => {
|
||||
state.callbacks.onStartTransaction?.();
|
||||
return state;
|
||||
});
|
||||
});
|
||||
subscriptions.subscribeFrontendMessage("ColorPickerCommitHistoryTransaction", () => {
|
||||
update((state) => {
|
||||
state.callbacks.onCommitTransaction?.();
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("ColorPickerPickersAndGradient", (diffs) => {
|
||||
update((state) => {
|
||||
patchLayout(state.pickersAndGradient, diffs);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
subscriptions.subscribeLayoutUpdate("ColorPickerDetails", (diffs) => {
|
||||
update((state) => {
|
||||
patchLayout(state.details, diffs);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setCallbacks: (callbacks: ColorPickerCallbacks) => {
|
||||
update((state) => {
|
||||
state.callbacks = callbacks;
|
||||
return state;
|
||||
});
|
||||
},
|
||||
clearCallbacks: (expected: ColorPickerCallbacks) => {
|
||||
update((state) => {
|
||||
state.isDragging = false;
|
||||
if (state.callbacks === expected) {
|
||||
state.callbacks = {};
|
||||
}
|
||||
return state;
|
||||
});
|
||||
},
|
||||
setDragging: (dragging: boolean) => {
|
||||
update((state) => {
|
||||
state.isDragging = dragging;
|
||||
return state;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function destroyColorPickerStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("ColorPickerColorChanged");
|
||||
subscriptions.unsubscribeFrontendMessage("ColorPickerStartHistoryTransaction");
|
||||
subscriptions.unsubscribeFrontendMessage("ColorPickerCommitHistoryTransaction");
|
||||
subscriptions.unsubscribeLayoutUpdate("ColorPickerPickersAndGradient");
|
||||
subscriptions.unsubscribeLayoutUpdate("ColorPickerDetails");
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { tick } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { IconName } from "/src/icons";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { EditorWrapper, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type DialogStore = ReturnType<typeof createDialogStore>;
|
||||
|
||||
type DialogStoreState = {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
icon: IconName | undefined;
|
||||
buttons: Layout;
|
||||
column1: Layout;
|
||||
column2: Layout;
|
||||
panicDetails: string;
|
||||
};
|
||||
const initialState: DialogStoreState = {
|
||||
visible: false,
|
||||
title: "",
|
||||
icon: undefined,
|
||||
buttons: [],
|
||||
column1: [],
|
||||
column2: [],
|
||||
// Special case for the crash dialog because we cannot handle button widget callbacks from Rust once the editor has panicked
|
||||
panicDetails: "",
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<DialogStoreState> = import.meta.hot?.data?.store || writable<DialogStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createDialogStore(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
|
||||
destroyDialogStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("DisplayDialog", (data) => {
|
||||
update((state) => {
|
||||
state.visible = true;
|
||||
|
||||
state.title = data.title;
|
||||
state.icon = data.icon;
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("DialogButtons", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.buttons, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("DialogColumn1", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.column1, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("DialogColumn2", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.column2, data);
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("DialogClose", () => {
|
||||
update((state) => {
|
||||
// Disallow dismissing the crash dialog since it should remain as the final notification
|
||||
if (state.panicDetails === "") state.visible = false;
|
||||
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerDisplayThirdPartyLicensesDialog", async () => {
|
||||
const BACKUP_URL = "https://editor.graphite.art/third-party-licenses.txt";
|
||||
let licenseText = `Content was not able to load. Please check your network connection and try again.\n\nOr visit ${BACKUP_URL} for the license notices.`;
|
||||
|
||||
try {
|
||||
const response = await fetch("/third-party-licenses.txt");
|
||||
if (response.ok && response.headers.get("Content-Type")?.includes("text/plain")) licenseText = await response.text();
|
||||
} catch {
|
||||
// Do nothing on network error
|
||||
}
|
||||
|
||||
editor.requestLicensesThirdPartyDialogWithLicenseText(licenseText);
|
||||
});
|
||||
|
||||
return { subscribe };
|
||||
}
|
||||
|
||||
export function destroyDialogStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("DisplayDialog");
|
||||
subscriptions.unsubscribeFrontendMessage("DialogClose");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerDisplayThirdPartyLicensesDialog");
|
||||
subscriptions.unsubscribeLayoutUpdate("DialogButtons");
|
||||
subscriptions.unsubscribeLayoutUpdate("DialogColumn1");
|
||||
subscriptions.unsubscribeLayoutUpdate("DialogColumn2");
|
||||
}
|
||||
|
||||
// Creates a crash dialog from JS once the editor has panicked.
|
||||
// Normal dialogs are created in the Rust backend, but for the crash dialog, the editor has panicked so it cannot respond to widget callbacks.
|
||||
export function createCrashDialog(panicDetails: string) {
|
||||
update((state) => {
|
||||
state.visible = true;
|
||||
|
||||
state.icon = "Failure";
|
||||
state.title = "Crash";
|
||||
state.panicDetails = panicDetails;
|
||||
|
||||
state.column1 = [];
|
||||
state.column2 = [];
|
||||
state.buttons = [];
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { tick } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type DocumentStore = ReturnType<typeof createDocumentStore>;
|
||||
|
||||
type DocumentStoreState = {
|
||||
toolOptionsLayout: Layout;
|
||||
documentBarLayout: Layout;
|
||||
toolShelfLayout: Layout;
|
||||
workingColorsLayout: Layout;
|
||||
nodeGraphControlBarLayout: Layout;
|
||||
graphViewOverlayOpen: boolean;
|
||||
fadeArtwork: number;
|
||||
};
|
||||
const initialState: DocumentStoreState = {
|
||||
toolOptionsLayout: [],
|
||||
documentBarLayout: [],
|
||||
toolShelfLayout: [],
|
||||
workingColorsLayout: [],
|
||||
nodeGraphControlBarLayout: [],
|
||||
graphViewOverlayOpen: false,
|
||||
fadeArtwork: 100,
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<DocumentStoreState> = import.meta.hot?.data?.store || writable<DocumentStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createDocumentStore(subscriptions: SubscriptionsRouter) {
|
||||
destroyDocumentStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateGraphFadeArtwork", (data) => {
|
||||
update((state) => {
|
||||
state.fadeArtwork = data.percentage;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("ToolOptions", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.toolOptionsLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("DocumentBar", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.documentBarLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("ToolShelf", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.toolShelfLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("WorkingColors", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.workingColorsLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("NodeGraphControlBar", async (data) => {
|
||||
await tick();
|
||||
|
||||
update((state) => {
|
||||
patchLayout(state.nodeGraphControlBarLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateGraphViewOverlay", (data) => {
|
||||
update((state) => {
|
||||
state.graphViewOverlayOpen = data.open;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return { subscribe };
|
||||
}
|
||||
|
||||
export function destroyDocumentStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateGraphFadeArtwork");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateGraphViewOverlay");
|
||||
subscriptions.unsubscribeLayoutUpdate("ToolOptions");
|
||||
subscriptions.unsubscribeLayoutUpdate("DocumentBar");
|
||||
subscriptions.unsubscribeLayoutUpdate("ToolShelf");
|
||||
subscriptions.unsubscribeLayoutUpdate("WorkingColors");
|
||||
subscriptions.unsubscribeLayoutUpdate("NodeGraphControlBar");
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { get, writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
|
||||
export type FullscreenStore = ReturnType<typeof createFullscreenStore>;
|
||||
|
||||
type FullscreenStoreState = {
|
||||
windowFullscreen: boolean;
|
||||
keyboardLocked: boolean;
|
||||
};
|
||||
const initialState: FullscreenStoreState = {
|
||||
windowFullscreen: false,
|
||||
keyboardLocked: false,
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<FullscreenStoreState> = import.meta.hot?.data?.store || writable<FullscreenStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createFullscreenStore(subscriptions: SubscriptionsRouter) {
|
||||
destroyFullscreenStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("WindowFullscreen", () => {
|
||||
toggleFullscreen();
|
||||
});
|
||||
|
||||
return { subscribe };
|
||||
}
|
||||
|
||||
export function destroyFullscreenStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("WindowFullscreen");
|
||||
}
|
||||
|
||||
export function fullscreenModeChanged() {
|
||||
update((state) => {
|
||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
export async function enterFullscreen() {
|
||||
await document.documentElement.requestFullscreen();
|
||||
|
||||
const keyboardLockApiSupported = navigator.keyboard !== undefined && "lock" in navigator.keyboard;
|
||||
|
||||
if (keyboardLockApiSupported && navigator.keyboard) {
|
||||
await navigator.keyboard.lock(["ControlLeft", "ControlRight"]);
|
||||
|
||||
update((state) => {
|
||||
state.keyboardLocked = true;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function exitFullscreen() {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
|
||||
export async function toggleFullscreen() {
|
||||
const state = get(store);
|
||||
if (state.windowFullscreen) await exitFullscreen();
|
||||
else await enterFullscreen();
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import type { MessageBody } from "/src/subscriptions-router";
|
||||
import type { NodeGraphErrorDiagnostic, BoxSelection, FrontendClickTargets, ContextMenuInformation, FrontendNode, FrontendNodeType, WirePath } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type NodeGraphStore = ReturnType<typeof createNodeGraphStore>;
|
||||
|
||||
export type NodeGraphTransform = { scale: number; x: number; y: number };
|
||||
|
||||
type NodeGraphStoreState = {
|
||||
box: BoxSelection | undefined;
|
||||
clickTargets: FrontendClickTargets | undefined;
|
||||
contextMenuInformation: ContextMenuInformation | undefined;
|
||||
error: NodeGraphErrorDiagnostic | undefined;
|
||||
layerWidths: Map<bigint, number>;
|
||||
chainWidths: Map<bigint, number>;
|
||||
hasLeftInputWire: Map<bigint, boolean>;
|
||||
nodes: Map<bigint, FrontendNode>;
|
||||
wirePathInProgress: WirePath | undefined;
|
||||
nodeDescriptions: Map<string, string>;
|
||||
nodeTypes: FrontendNodeType[];
|
||||
thumbnails: Map<bigint, string>;
|
||||
selected: bigint[];
|
||||
inSelectedNetwork: boolean;
|
||||
reorderImportIndex: number | undefined;
|
||||
reorderExportIndex: number | undefined;
|
||||
};
|
||||
const initialState: NodeGraphStoreState = {
|
||||
box: undefined,
|
||||
clickTargets: undefined,
|
||||
contextMenuInformation: undefined,
|
||||
error: undefined,
|
||||
layerWidths: new Map(),
|
||||
chainWidths: new Map(),
|
||||
hasLeftInputWire: new Map(),
|
||||
nodes: new Map(),
|
||||
wirePathInProgress: undefined,
|
||||
nodeDescriptions: new Map(),
|
||||
nodeTypes: [],
|
||||
thumbnails: new Map(),
|
||||
selected: [],
|
||||
inSelectedNetwork: true,
|
||||
reorderImportIndex: undefined,
|
||||
reorderExportIndex: undefined,
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<NodeGraphStoreState> = import.meta.hot?.data?.store || writable<NodeGraphStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
// Separate transform store so pan/zoom updates don't trigger re-rendering the entire node graph
|
||||
const transformStore: Writable<NodeGraphTransform> = import.meta.hot?.data?.transformStore || writable<NodeGraphTransform>({ scale: 1, x: 0, y: 0 });
|
||||
if (import.meta.hot) import.meta.hot.data.transformStore = transformStore;
|
||||
|
||||
// Separate imports/exports store so viewport-anchored position updates don't trigger node re-renders
|
||||
const importsExportsStore: Writable<MessageBody<"UpdateImportsExports"> | undefined> = import.meta.hot?.data?.importsExportsStore || writable(undefined);
|
||||
if (import.meta.hot) import.meta.hot.data.importsExportsStore = importsExportsStore;
|
||||
|
||||
// Separate visible nodes store so viewport culling changes don't trigger full node re-renders
|
||||
const visibleNodesStore: Writable<Set<bigint>> = import.meta.hot?.data?.visibleNodesStore || writable(new Set());
|
||||
if (import.meta.hot) import.meta.hot.data.visibleNodesStore = visibleNodesStore;
|
||||
|
||||
// Separate wires store so wire path updates (e.g. export connector movement during pan) don't trigger node re-renders
|
||||
const wiresStore: Writable<Map<bigint, Map<number, WirePath>>> = import.meta.hot?.data?.wiresStore || writable(new Map());
|
||||
if (import.meta.hot) import.meta.hot.data.wiresStore = wiresStore;
|
||||
|
||||
export function createNodeGraphStore(subscriptions: SubscriptionsRouter) {
|
||||
destroyNodeGraphStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("SendUIMetadata", (data) => {
|
||||
update((state) => {
|
||||
state.nodeDescriptions = new Map(data.nodeDescriptions);
|
||||
state.nodeTypes = data.nodeTypes;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateBox", (data) => {
|
||||
update((state) => {
|
||||
state.box = data.box;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateClickTargets", (data) => {
|
||||
update((state) => {
|
||||
state.clickTargets = data.clickTargets;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateContextMenuInformation", (data) => {
|
||||
update((state) => {
|
||||
state.contextMenuInformation = data.contextMenuInformation;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateImportReorderIndex", (data) => {
|
||||
update((state) => {
|
||||
state.reorderImportIndex = data.importIndex;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateExportReorderIndex", (data) => {
|
||||
update((state) => {
|
||||
state.reorderExportIndex = data.exportIndex;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateImportsExports", (data) => {
|
||||
importsExportsStore.set(data);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateInSelectedNetwork", (data) => {
|
||||
update((state) => {
|
||||
state.inSelectedNetwork = data.inSelectedNetwork;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateLayerWidths", (data) => {
|
||||
update((state) => {
|
||||
state.layerWidths = data.layerWidths;
|
||||
state.chainWidths = data.chainWidths;
|
||||
state.hasLeftInputWire = data.hasLeftInputWire;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateNodeGraphNodes", (data) => {
|
||||
update((state) => {
|
||||
state.nodes.clear();
|
||||
data.nodes.forEach((node) => {
|
||||
state.nodes.set(node.id, node);
|
||||
});
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateNodeGraphErrorDiagnostic", (data) => {
|
||||
update((state) => {
|
||||
state.error = data.error;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateVisibleNodes", (data) => {
|
||||
const newNodes = new Set<bigint>(data.nodes);
|
||||
|
||||
// Short-circuit when the visible set hasn't changed to avoid unnecessary re-renders
|
||||
let changed = false;
|
||||
const unsubscribe = visibleNodesStore.subscribe((current) => {
|
||||
if (current.size !== newNodes.size) {
|
||||
changed = true;
|
||||
} else {
|
||||
newNodes.forEach((node) => {
|
||||
if (!current.has(node)) changed = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
visibleNodesStore.set(newNodes);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateNodeGraphWires", (data) => {
|
||||
if (data.wires.length === 0) return;
|
||||
|
||||
wiresStore.update((wires) => {
|
||||
data.wires.forEach((wireUpdate) => {
|
||||
let inputMap = wires.get(wireUpdate.id);
|
||||
if (!inputMap) {
|
||||
inputMap = new Map();
|
||||
wires.set(wireUpdate.id, inputMap);
|
||||
}
|
||||
if (wireUpdate.wirePathUpdate !== undefined) {
|
||||
inputMap.set(wireUpdate.inputIndex, wireUpdate.wirePathUpdate);
|
||||
} else {
|
||||
inputMap.delete(wireUpdate.inputIndex);
|
||||
}
|
||||
});
|
||||
return wires;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("ClearAllNodeGraphWires", () => {
|
||||
wiresStore.set(new Map());
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateNodeGraphSelection", (data) => {
|
||||
update((state) => {
|
||||
state.selected = data.selected;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateNodeGraphTransform", (data) => {
|
||||
transformStore.set({ scale: data.scale, x: data.translation[0], y: data.translation[1] });
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateNodeThumbnail", (data) => {
|
||||
update((state) => {
|
||||
state.thumbnails.set(data.id, data.value);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateWirePathInProgress", (data) => {
|
||||
update((state) => {
|
||||
state.wirePathInProgress = data.wirePath;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return { subscribe, transformStore, importsExportsStore, visibleNodesStore, wiresStore };
|
||||
}
|
||||
|
||||
export function destroyNodeGraphStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("SendUIMetadata");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateBox");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateClickTargets");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateContextMenuInformation");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateImportReorderIndex");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateExportReorderIndex");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateImportsExports");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateInSelectedNetwork");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateLayerWidths");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphNodes");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphErrorDiagnostic");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateVisibleNodes");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphWires");
|
||||
subscriptions.unsubscribeFrontendMessage("ClearAllNodeGraphWires");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphSelection");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateNodeGraphTransform");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateNodeThumbnail");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateWirePathInProgress");
|
||||
}
|
||||
|
||||
export function closeContextMenu() {
|
||||
update((state) => {
|
||||
state.contextMenuInformation = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { DockingSplitDirection, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type DockingEdge = DockingSplitDirection | "Center";
|
||||
|
||||
export type PanelDragState = {
|
||||
active: boolean;
|
||||
sourcePanelId: string | undefined;
|
||||
draggedTabs: PanelType[];
|
||||
sourceTabIndex: number;
|
||||
// Whether we're dragging an entire tab group (via the tab bar background) vs a single tab
|
||||
draggingGroup: boolean;
|
||||
// Hover state for tab bar insertion (existing behavior)
|
||||
hoverTargetPanelId: string | undefined;
|
||||
hoverInsertionIndex: number | undefined;
|
||||
hoverInsertionMarkerLeft: number | undefined;
|
||||
// Hover state for edge docking (new split creation)
|
||||
hoverDockingPanelId: string | undefined;
|
||||
hoverDockingEdge: DockingEdge | undefined;
|
||||
};
|
||||
|
||||
const initialState: PanelDragState = {
|
||||
active: false,
|
||||
sourcePanelId: undefined,
|
||||
draggedTabs: [],
|
||||
sourceTabIndex: 0,
|
||||
draggingGroup: false,
|
||||
hoverTargetPanelId: undefined,
|
||||
hoverInsertionIndex: undefined,
|
||||
hoverInsertionMarkerLeft: undefined,
|
||||
hoverDockingPanelId: undefined,
|
||||
hoverDockingEdge: undefined,
|
||||
};
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<PanelDragState> = import.meta.hot?.data?.store || writable<PanelDragState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
|
||||
export const panelDrag = store;
|
||||
|
||||
export function startCrossPanelDrag(sourcePanelId: string, draggedTabs: PanelType[], sourceTabIndex: number, draggingGroup: boolean) {
|
||||
store.update((state) => {
|
||||
state.active = true;
|
||||
state.sourcePanelId = sourcePanelId;
|
||||
state.draggedTabs = draggedTabs;
|
||||
state.sourceTabIndex = sourceTabIndex;
|
||||
state.draggingGroup = draggingGroup;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
export function endCrossPanelDrag() {
|
||||
store.update((state) => {
|
||||
state.active = false;
|
||||
state.sourcePanelId = undefined;
|
||||
state.draggedTabs = [];
|
||||
state.sourceTabIndex = 0;
|
||||
state.draggingGroup = false;
|
||||
state.hoverTargetPanelId = undefined;
|
||||
state.hoverInsertionIndex = undefined;
|
||||
state.hoverInsertionMarkerLeft = undefined;
|
||||
state.hoverDockingPanelId = undefined;
|
||||
state.hoverDockingEdge = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCrossPanelHover(hoverTargetPanelId: string | undefined, hoverInsertionIndex: number | undefined, hoverInsertionMarkerLeft: number | undefined) {
|
||||
store.update((state) => {
|
||||
state.hoverTargetPanelId = hoverTargetPanelId;
|
||||
state.hoverInsertionIndex = hoverInsertionIndex;
|
||||
state.hoverInsertionMarkerLeft = hoverInsertionMarkerLeft;
|
||||
// Clear docking state when hovering a tab bar
|
||||
state.hoverDockingPanelId = undefined;
|
||||
state.hoverDockingEdge = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDockingHover(panelId: string | undefined, edge: DockingEdge | undefined) {
|
||||
store.update((state) => {
|
||||
state.hoverDockingPanelId = panelId;
|
||||
state.hoverDockingEdge = edge;
|
||||
// Clear tab bar insertion state when hovering an edge
|
||||
state.hoverTargetPanelId = undefined;
|
||||
state.hoverInsertionIndex = undefined;
|
||||
state.hoverInsertionMarkerLeft = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { tick } from "svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
|
||||
import { rasterizeSVG } from "/src/utility-functions/rasterization";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type PortfolioStore = ReturnType<typeof createPortfolioStore>;
|
||||
|
||||
type PortfolioStoreState = {
|
||||
unsaved: boolean;
|
||||
documents: DocumentInfo[];
|
||||
activeDocumentIndex: number;
|
||||
panelLayout: WorkspacePanelLayout;
|
||||
layerCache: SvelteMap<string, LayerPanelEntry>;
|
||||
layerStructure: LayerStructureEntry[];
|
||||
};
|
||||
const initialState: PortfolioStoreState = {
|
||||
unsaved: false,
|
||||
documents: [],
|
||||
activeDocumentIndex: 0,
|
||||
panelLayout: {},
|
||||
layerCache: new SvelteMap<string, LayerPanelEntry>(),
|
||||
layerStructure: [],
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<PortfolioStoreState> = import.meta.hot?.data?.store || writable<PortfolioStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export const welcomeScreenButtonsLayout = makeLayoutStore("welcomeScreenButtonsLayout");
|
||||
export const propertiesPanelLayout = makeLayoutStore("propertiesPanelLayout");
|
||||
export const dataPanelLayout = makeLayoutStore("dataPanelLayout");
|
||||
export const layersPanelControlBarLeftLayout = makeLayoutStore("layersPanelControlBarLeftLayout");
|
||||
export const layersPanelControlBarRightLayout = makeLayoutStore("layersPanelControlBarRightLayout");
|
||||
export const layersPanelBottomBarLayout = makeLayoutStore("layersPanelBottomBarLayout");
|
||||
|
||||
// Each panel layout has its own dedicated store so a layout update only re-renders that panel's consumers.
|
||||
// Putting them at module scope (not inside the component) lets them survive a Svelte remount during a
|
||||
// panel-tree restructure, since the backend's diff-based updates aren't re-sent on subscribe.
|
||||
function makeLayoutStore(name: string): Writable<Layout> {
|
||||
const persisted = import.meta.hot?.data?.[name];
|
||||
const layoutStore: Writable<Layout> = persisted || writable<Layout>([]);
|
||||
if (import.meta.hot) import.meta.hot.data[name] = layoutStore;
|
||||
return layoutStore;
|
||||
}
|
||||
|
||||
function patchLayoutStore(layoutStore: Writable<Layout>, data: Parameters<typeof patchLayout>[1]) {
|
||||
layoutStore.update((layout) => {
|
||||
patchLayout(layout, data);
|
||||
return layout;
|
||||
});
|
||||
}
|
||||
|
||||
export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
|
||||
destroyPortfolioStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateOpenDocumentsList", (data) => {
|
||||
update((state) => {
|
||||
state.documents = data.openDocuments;
|
||||
if (state.documents.length === 0) state.activeDocumentIndex = 0;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateActiveDocument", (data) => {
|
||||
update((state) => {
|
||||
// Assume we receive a correct document id
|
||||
const activeId = state.documents.findIndex((doc) => doc.id === data.documentId);
|
||||
state.activeDocumentIndex = activeId;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerFetchAndOpenDocument", async (data) => {
|
||||
try {
|
||||
const url = new URL(`demo-artwork/${data.filename}`, document.location.href);
|
||||
const response = await fetch(url);
|
||||
editor.openFile(data.filename, await response.bytes());
|
||||
} catch {
|
||||
// Needs to be delayed until the end of the current call stack so the existing demo artwork dialog can be closed first, otherwise this dialog won't show
|
||||
setTimeout(() => {
|
||||
editor.errorDialog("Failed to open document", "The file could not be reached over the internet. You may be offline, or it may be missing.");
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerOpen", async () => {
|
||||
const files = await upload(`image/*,.${editor.fileExtension()},.${editor.gddFileExtension()}`, "data", true);
|
||||
files.forEach((file) => editor.openFile(file.filename, file.content));
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerImport", async () => {
|
||||
// TODO: Use the same `accept` string as in the `TriggerOpen` handler once importing Graphite documents as nodes is supported
|
||||
const data = await upload("image/*", "data");
|
||||
editor.importFile(data.filename, data.content);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerSaveDocument", (data) => {
|
||||
downloadFile(data.name, data.content);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerSaveFile", (data) => {
|
||||
downloadFile(data.name, data.content);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerExportImage", async (data) => {
|
||||
const { svg, name, mime, size } = data;
|
||||
|
||||
// Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black)
|
||||
const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined;
|
||||
|
||||
// Rasterize the SVG to an image file
|
||||
try {
|
||||
const blob = await rasterizeSVG(svg, size[0], size[1], mime, backgroundColor);
|
||||
|
||||
// Have the browser download the file to the user's disk
|
||||
downloadFileBlob(name, blob);
|
||||
} catch {
|
||||
// Fail silently if there's an error rasterizing the SVG, such as a zero-sized image
|
||||
}
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateWorkspacePanelLayout", (data) => {
|
||||
update((state) => {
|
||||
state.panelLayout = data.panelLayout;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
// Each panel layout uses its own store so updates only re-render that panel's consumers
|
||||
subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", async (data) => {
|
||||
await tick();
|
||||
patchLayoutStore(welcomeScreenButtonsLayout, data);
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("PropertiesPanel", async (data) => {
|
||||
await tick();
|
||||
patchLayoutStore(propertiesPanelLayout, data);
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("DataPanel", async (data) => {
|
||||
await tick();
|
||||
patchLayoutStore(dataPanelLayout, data);
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", async (data) => {
|
||||
await tick();
|
||||
patchLayoutStore(layersPanelControlBarLeftLayout, data);
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelControlRightBar", async (data) => {
|
||||
await tick();
|
||||
patchLayoutStore(layersPanelControlBarRightLayout, data);
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelBottomBar", async (data) => {
|
||||
await tick();
|
||||
patchLayoutStore(layersPanelBottomBarLayout, data);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => {
|
||||
update((state) => {
|
||||
state.layerStructure = data.layerStructure;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateDocumentLayerDetails", (data) => {
|
||||
update((state) => {
|
||||
state.layerCache.set(String(data.data.id), data.data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return { subscribe };
|
||||
}
|
||||
|
||||
export function destroyPortfolioStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateOpenDocumentsList");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateActiveDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerFetchAndOpenDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerOpen");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerImport");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSaveFile");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerExportImage");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout");
|
||||
subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons");
|
||||
subscriptions.unsubscribeLayoutUpdate("PropertiesPanel");
|
||||
subscriptions.unsubscribeLayoutUpdate("DataPanel");
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelControlLeftBar");
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelControlRightBar");
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelBottomBar");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerStructure");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerDetails");
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { operatingSystem } from "/src/utility-functions/platform";
|
||||
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type TooltipStore = ReturnType<typeof createTooltipStore>;
|
||||
|
||||
const SHOW_TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
type TooltipStoreState = {
|
||||
visible: boolean;
|
||||
element: Element | undefined;
|
||||
position: { x: number; y: number };
|
||||
shiftClickShortcut: ActionShortcut | undefined;
|
||||
altClickShortcut: ActionShortcut | undefined;
|
||||
fullscreenShortcut: ActionShortcut | undefined;
|
||||
};
|
||||
const initialState: TooltipStoreState = {
|
||||
visible: false,
|
||||
element: undefined,
|
||||
position: { x: 0, y: 0 },
|
||||
shiftClickShortcut: undefined,
|
||||
altClickShortcut: undefined,
|
||||
fullscreenShortcut: undefined,
|
||||
};
|
||||
|
||||
type Listener = { eventName: keyof DocumentEventMap; action(event: Event): void };
|
||||
const tooltipEventListeners: Listener[] = [
|
||||
{ eventName: "mouseover", action: onMouseOver },
|
||||
{ eventName: "mousemove", action: onMouseMove },
|
||||
{ eventName: "mouseleave", action: onMouseLeave },
|
||||
{ eventName: "mousedown", action: closeTooltip },
|
||||
{ eventName: "keydown", action: closeTooltip },
|
||||
{ eventName: "wheel", action: closeTooltip },
|
||||
];
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
let tooltipTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||
const store: Writable<TooltipStoreState> = import.meta.hot?.data?.store || writable<TooltipStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export function createTooltipStore(subscriptions: SubscriptionsRouter) {
|
||||
destroyTooltipStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("SendShortcutShiftClick", async (data) => {
|
||||
update((state) => {
|
||||
state.shiftClickShortcut = data.shortcut;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("SendShortcutAltClick", async (data) => {
|
||||
update((state) => {
|
||||
state.altClickShortcut = data.shortcut;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("SendShortcutFullscreen", async (data) => {
|
||||
update((state) => {
|
||||
state.fullscreenShortcut = operatingSystem() === "Mac" ? data.shortcutMac : data.shortcut;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
tooltipEventListeners.forEach(({ eventName, action }) => document.addEventListener(eventName, action));
|
||||
|
||||
return { subscribe };
|
||||
}
|
||||
|
||||
export function destroyTooltipStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
if (tooltipTimeout) clearTimeout(tooltipTimeout);
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("SendShortcutShiftClick");
|
||||
subscriptions.unsubscribeFrontendMessage("SendShortcutAltClick");
|
||||
subscriptions.unsubscribeFrontendMessage("SendShortcutFullscreen");
|
||||
|
||||
tooltipEventListeners.forEach(({ eventName, action }) => document.removeEventListener(eventName, action));
|
||||
}
|
||||
|
||||
// Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip
|
||||
function onMouseOver(e: MouseEvent) {
|
||||
const element = (e.target instanceof Element && e.target.closest("[data-tooltip-label], [data-tooltip-description], [data-tooltip-shortcut]")) || undefined;
|
||||
|
||||
update((state) => {
|
||||
state.visible = false;
|
||||
state.element = element;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for mouse movements to schedule and position the tooltip, or hide it immediately upon further movement
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
// Hide the tooltip now that the cursor has moved
|
||||
update((state) => {
|
||||
state.visible = false;
|
||||
return state;
|
||||
});
|
||||
|
||||
// Before we schedule a new future tooltip appearance, we clear the existing one
|
||||
if (tooltipTimeout) clearTimeout(tooltipTimeout);
|
||||
|
||||
// Don't show tooltips while mouse buttons are pressed
|
||||
if (e.buttons !== 0) return;
|
||||
|
||||
// Schedule the tooltip to appear at this cursor position after a delay
|
||||
tooltipTimeout = setTimeout(() => {
|
||||
update((state) => {
|
||||
if (state.element) {
|
||||
state.visible = true;
|
||||
state.position = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
return state;
|
||||
});
|
||||
}, SHOW_TOOLTIP_DELAY_MS);
|
||||
}
|
||||
|
||||
// Hide tooltip and cancel any pending timeout when the mouse leaves the application window
|
||||
function onMouseLeave() {
|
||||
if (tooltipTimeout) clearTimeout(tooltipTimeout);
|
||||
closeTooltip();
|
||||
}
|
||||
|
||||
// Stop showing a tooltip if the user clicks or presses a key, and require the user to first move out of the element before it can re-appear
|
||||
function closeTooltip() {
|
||||
update((state) => {
|
||||
state.visible = false;
|
||||
state.element = undefined;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { FrontendMessage, LayoutTarget, WidgetDiff } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Type convert a union of messages into a map of messages
|
||||
export type ToMessageMap<T> = {
|
||||
[K in T extends string ? T : T extends object ? keyof T : never]: K extends T ? Record<string, never> : T extends Record<K, infer Payload> ? Payload : never;
|
||||
};
|
||||
|
||||
export type MessageMap = ToMessageMap<FrontendMessage>;
|
||||
export type MessageName = keyof MessageMap;
|
||||
export type MessageBody<T extends MessageName> = Extract<FrontendMessage, Record<T, unknown>>[T];
|
||||
|
||||
export function createSubscriptionsRouter() {
|
||||
// Callbacks are wrapped at subscription time to capture their type-specific data extraction in a closure,
|
||||
// so the stored function has a uniform signature and the map doesn't need per-key generic value types.
|
||||
const subscriptions = new Map<MessageName, (taggedMessage: MessageMap) => void>();
|
||||
const layoutCallbacks = new Map<LayoutTarget, (diffs: WidgetDiff[]) => void>();
|
||||
|
||||
const subscribeFrontendMessage = <T extends MessageName>(messageType: T, callback: (data: MessageMap[T]) => void) => {
|
||||
subscriptions.set(messageType, (taggedMessage: MessageMap) => callback(taggedMessage[messageType]));
|
||||
};
|
||||
|
||||
const unsubscribeFrontendMessage = (messageType: MessageName) => {
|
||||
subscriptions.delete(messageType);
|
||||
};
|
||||
|
||||
const subscribeLayoutUpdate = (target: LayoutTarget, callback: (diffs: WidgetDiff[]) => void) => {
|
||||
layoutCallbacks.set(target, callback);
|
||||
};
|
||||
|
||||
const unsubscribeLayoutUpdate = (target: LayoutTarget) => {
|
||||
layoutCallbacks.delete(target);
|
||||
};
|
||||
|
||||
function normalizeMessage<T extends string | object>(message: T): ToMessageMap<T>;
|
||||
function normalizeMessage(message: string | Record<string, unknown>): Record<string, unknown> {
|
||||
// If it's a bare string, convert it to an object with an empty payload
|
||||
if (typeof message === "string") {
|
||||
const result: Record<string, Record<string, never>> = { [message]: {} };
|
||||
return result;
|
||||
}
|
||||
|
||||
// If it's already an object, it matches the structure of our map
|
||||
return message;
|
||||
}
|
||||
|
||||
const handleFrontendMessage = (messageType: MessageName, messageData: FrontendMessage) => {
|
||||
// Messages with non-empty data are provided by Serde JSON as an object with one key as the message name, like: { NameOfThisMessage: { ... } }
|
||||
// Messages with empty data are provided by Serde JSON as a string with the message name, like: "NameOfThisMessage"
|
||||
// Here we extract the payload object or create an empty payload object, as needed.
|
||||
const taggedMessage = normalizeMessage(messageData);
|
||||
|
||||
// Resolve the dispatch thunk, depending on whether this is a layout update or a regular message.
|
||||
// UpdateLayout messages are dispatched to layout-specific callbacks based on the layout target.
|
||||
// The thunk is re-evaluated on each retry because the callback may not be registered yet.
|
||||
let getHandler: () => ((taggedMessage: MessageMap) => void) | undefined = () => subscriptions.get(messageType);
|
||||
|
||||
// Handle layout updates specially to route them to layout-specific callbacks and extract the diffs as the data to pass
|
||||
let target: LayoutTarget | undefined;
|
||||
if ("UpdateLayout" in taggedMessage) {
|
||||
const { layoutTarget, diff } = taggedMessage["UpdateLayout"];
|
||||
target = layoutTarget;
|
||||
|
||||
getHandler = () => {
|
||||
const layoutCallback = layoutCallbacks.get(layoutTarget);
|
||||
if (!layoutCallback) return undefined;
|
||||
return () => layoutCallback(diff);
|
||||
};
|
||||
}
|
||||
|
||||
// Try to execute the callback. Due to message ordering, the callback may not be registered yet,
|
||||
// so we retry a few times on the next stack frame to give onMount a chance to run.
|
||||
let retries = 0;
|
||||
const callCallback = () => {
|
||||
const handler = getHandler();
|
||||
|
||||
if (handler) {
|
||||
handler(taggedMessage);
|
||||
}
|
||||
// Try again on the next stack frame, if the retry limit hasn't been exceeded yet
|
||||
else if (retries <= 3) {
|
||||
retries += 1;
|
||||
setTimeout(callCallback, 0);
|
||||
}
|
||||
// Guard against this firing after a teardown during HMR, if no handlers are registered anymore
|
||||
else if (subscriptions.size + layoutCallbacks.size > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Received a frontend message of type ${messageType}${target ? ` (${target})` : ""} but no handler was registered for it from the client.`);
|
||||
}
|
||||
};
|
||||
|
||||
callCallback();
|
||||
};
|
||||
|
||||
return {
|
||||
subscribeFrontendMessage,
|
||||
unsubscribeFrontendMessage,
|
||||
subscribeLayoutUpdate,
|
||||
unsubscribeLayoutUpdate,
|
||||
handleFrontendMessage,
|
||||
};
|
||||
}
|
||||
export type SubscriptionsRouter = ReturnType<typeof createSubscriptionsRouter>;
|
||||
@@ -0,0 +1,175 @@
|
||||
import { extractPixelData } from "/src/utility-functions/rasterization";
|
||||
import { stripIndents } from "/src/utility-functions/strip-indents";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export function readAtCaret(cut: boolean): string | undefined {
|
||||
const element = window.document.activeElement;
|
||||
|
||||
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
||||
const start = element.selectionStart;
|
||||
const end = element.selectionEnd;
|
||||
|
||||
if ((!start && start !== 0) || (!end && end !== 0) || start === end) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = element.value;
|
||||
const selectedText = value.slice(start, end);
|
||||
|
||||
if (cut) {
|
||||
element.value = value.slice(0, start) + value.slice(end);
|
||||
|
||||
element.selectionStart = element.selectionEnd = start;
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
return selectedText;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectedText = String(selection);
|
||||
if (!selectedText) return undefined;
|
||||
|
||||
if (cut) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
return selectedText;
|
||||
}
|
||||
|
||||
export function insertAtCaret(text: string) {
|
||||
const element = window.document.activeElement;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
||||
const start = element.selectionStart;
|
||||
const end = element.selectionEnd;
|
||||
|
||||
if ((!start && start !== 0) || (!end && end !== 0)) return;
|
||||
|
||||
const value = element.value;
|
||||
|
||||
element.value = value.slice(0, start) + text + value.slice(end);
|
||||
|
||||
const newPos = start + text.length;
|
||||
element.selectionStart = element.selectionEnd = newPos;
|
||||
} else if (element instanceof HTMLElement && element.isContentEditable) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
|
||||
const textNode = window.document.createTextNode(text);
|
||||
range.insertNode(textNode);
|
||||
|
||||
range.setStartAfter(textNode);
|
||||
range.collapse(true);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
export async function triggerClipboardRead(editor: EditorWrapper) {
|
||||
// In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers
|
||||
// In the catch block, explain to the user why the paste failed and how to fix or work around the problem
|
||||
try {
|
||||
// Attempt to check if the clipboard permission is denied, and throw an error if that is the case
|
||||
// In Firefox, the `clipboard-read` permission isn't supported, so attempting to query it throws an error
|
||||
// In Safari, the entire Permissions API isn't supported, so the query never occurs and this block is skipped without an error and we assume we might have permission
|
||||
const permission = await navigator.permissions?.query({ name: "clipboard-read" });
|
||||
if (permission?.state === "denied") throw new Error("Permission denied");
|
||||
|
||||
// Read the clipboard contents if the Clipboard API is available
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
if (!clipboardItems) throw new Error("Clipboard API unsupported");
|
||||
|
||||
// Read any layer data or images from the clipboard
|
||||
const success = await Promise.any(
|
||||
Array.from(clipboardItems).map(async (item) => {
|
||||
// Read plain text and, if it is a layer, pass it to the editor
|
||||
if (item.types.includes("text/plain")) {
|
||||
const blob = await item.getType("text/plain");
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") editor.pasteText(reader.result);
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read an image from the clipboard and pass it to the editor to be loaded
|
||||
const imageType = item.types.find((type) => type.startsWith("image/"));
|
||||
|
||||
// Import the actual SVG content if it's an SVG
|
||||
if (imageType?.includes("svg")) {
|
||||
const blob = await item.getType("text/plain");
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") editor.pasteSvg(undefined, reader.result);
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import the bitmap image if it's an image
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const imageData = await extractPixelData(new Blob([reader.result], { type: imageType }));
|
||||
editor.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The API limits what kinds of data we can access, so we can get copied images and our text encodings of copied nodes, but not files (like
|
||||
// .graphite or even image files). However, the user can paste those with Ctrl+V, which we recommend they in the error message that's shown to them.
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
if (!success) throw new Error("No valid clipboard data");
|
||||
} catch (err) {
|
||||
const unsupported = stripIndents`
|
||||
This browser does not support reading from the clipboard.
|
||||
Use the standard keyboard shortcut to paste instead.
|
||||
`;
|
||||
const denied = stripIndents`
|
||||
The browser's clipboard permission has been denied.
|
||||
|
||||
Open the browser's website settings (usually accessible
|
||||
just left of the URL bar) to allow this permission.
|
||||
`;
|
||||
const nothing = stripIndents`
|
||||
No valid clipboard data was found. You may have better
|
||||
success pasting with the standard keyboard shortcut instead.
|
||||
`;
|
||||
|
||||
const matchMessage = {
|
||||
"clipboard-read": unsupported,
|
||||
"Clipboard API unsupported": unsupported,
|
||||
"Permission denied": denied,
|
||||
"No valid clipboard data": nothing,
|
||||
};
|
||||
const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err);
|
||||
|
||||
editor.errorDialog("Cannot access clipboard", message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { FillChoiceUI, GradientUI, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers
|
||||
export type HSV = { h: number; s: number; v: number };
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
|
||||
// COLOR FACTORY FUNCTIONS
|
||||
|
||||
export function createSRgba8(red: number, green: number, blue: number, alpha: number): SRGBA8 {
|
||||
return { red, green, blue, alpha };
|
||||
}
|
||||
|
||||
// Build an `SRGBA8` from HSVA components on the 0..1 range.
|
||||
export function createSRgba8FromHsva(h: number, s: number, v: number, a: number): SRGBA8 {
|
||||
const convert = (n: number): number => {
|
||||
const k = (n + h * 6) % 6;
|
||||
return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0);
|
||||
};
|
||||
|
||||
return {
|
||||
red: Math.round(convert(5) * 255),
|
||||
green: Math.round(convert(3) * 255),
|
||||
blue: Math.round(convert(1) * 255),
|
||||
alpha: Math.round(a * 255),
|
||||
};
|
||||
}
|
||||
|
||||
// COLOR UTILITY FUNCTIONS
|
||||
|
||||
export function isSRgba8(value: unknown): value is SRGBA8 {
|
||||
return typeof value === "object" && value !== null && "red" in value;
|
||||
}
|
||||
|
||||
// Parse a CSS color string into an `SRGBA8`. Uses a canvas to delegate parsing to the browser.
|
||||
export function sRgba8FromCSS(colorCode: string): SRGBA8 | undefined {
|
||||
// Allow single-digit hex value inputs
|
||||
let colorValue = colorCode.trim();
|
||||
if (colorValue.length === 2 && colorValue.charAt(0) === "#" && /[0-9a-f]/i.test(colorValue.charAt(1))) {
|
||||
const digit = colorValue.charAt(1);
|
||||
colorValue = `#${digit}${digit}${digit}`;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return undefined;
|
||||
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
|
||||
context.fillStyle = "black";
|
||||
context.fillStyle = colorValue;
|
||||
const comparisonA = context.fillStyle;
|
||||
|
||||
context.fillStyle = "white";
|
||||
context.fillStyle = colorValue;
|
||||
const comparisonB = context.fillStyle;
|
||||
|
||||
// Invalid color
|
||||
if (comparisonA !== comparisonB) {
|
||||
// If this color code didn't start with a #, add it and try again
|
||||
if (colorValue.trim().charAt(0) !== "#") return sRgba8FromCSS(`#${colorValue.trim()}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
|
||||
const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data];
|
||||
return createSRgba8(r, g, b, a);
|
||||
}
|
||||
|
||||
export function sRgba8ToHexNoAlpha(color: SRGBA8): string {
|
||||
const r = color.red.toString(16).padStart(2, "0");
|
||||
const g = color.green.toString(16).padStart(2, "0");
|
||||
const b = color.blue.toString(16).padStart(2, "0");
|
||||
|
||||
return `#${r}${g}${b}`;
|
||||
}
|
||||
|
||||
export function sRgba8ToRgb255(color: SRGBA8): RGB {
|
||||
return { r: color.red, g: color.green, b: color.blue };
|
||||
}
|
||||
|
||||
export function sRgba8ToRgbCSS(color: SRGBA8): string {
|
||||
return `rgb(${color.red}, ${color.green}, ${color.blue})`;
|
||||
}
|
||||
|
||||
export function sRgba8ToRgbaCSS(color: SRGBA8): string {
|
||||
return `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.alpha / 255})`;
|
||||
}
|
||||
|
||||
export function sRgba8ToHSV(color: SRGBA8): HSV {
|
||||
const r = color.red / 255;
|
||||
const g = color.green / 255;
|
||||
const b = color.blue / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
|
||||
const d = max - min;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
const v = max;
|
||||
|
||||
let h = 0;
|
||||
if (max !== min) {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return { h, s, v };
|
||||
}
|
||||
|
||||
export function sRgba8Opaque(color: SRGBA8): SRGBA8 {
|
||||
return createSRgba8(color.red, color.green, color.blue, 255);
|
||||
}
|
||||
|
||||
// WCAG-style relative luminance computed from an `SRGBA8` (alpha composited over white).
|
||||
export function sRgba8Luminance(color: SRGBA8): number {
|
||||
const a = color.alpha / 255;
|
||||
// Convert alpha into white
|
||||
const r = (color.red / 255) * a + (1 - a);
|
||||
const g = (color.green / 255) * a + (1 - a);
|
||||
const b = (color.blue / 255) * a + (1 - a);
|
||||
|
||||
// https://stackoverflow.com/a/3943023/775283
|
||||
|
||||
const linearR = r <= 0.04045 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
|
||||
const linearG = g <= 0.04045 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
|
||||
const linearB = b <= 0.04045 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
|
||||
|
||||
return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
|
||||
}
|
||||
|
||||
export function sRgba8ContrastingColor(color: SRGBA8 | undefined): "black" | "white" {
|
||||
if (!color) return "black";
|
||||
|
||||
const luminance = sRgba8Luminance(color);
|
||||
|
||||
return luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
|
||||
}
|
||||
|
||||
export function contrastingOutlineFactor(value: FillChoiceUI, proximityColor: string | [string, string], proximityRange: number): number {
|
||||
const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor];
|
||||
const [range1, range2] = pair.map((color) => sRgba8FromCSS(window.getComputedStyle(document.body).getPropertyValue(color)));
|
||||
|
||||
const contrast = (color: SRGBA8 | undefined): number => {
|
||||
if (!color) return 0;
|
||||
|
||||
const lum = sRgba8Luminance(color);
|
||||
let rangeLuminance1 = range1 ? sRgba8Luminance(range1) : 0;
|
||||
let rangeLuminance2 = range2 ? sRgba8Luminance(range2) : 0;
|
||||
[rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)];
|
||||
|
||||
const distance = Math.max(0, rangeLuminance1 - lum, lum - rangeLuminance2);
|
||||
|
||||
return (1 - Math.min(distance / proximityRange, 1)) * (1 - sRgba8ToHSV(color).s);
|
||||
};
|
||||
|
||||
const gradient = fillChoiceUIGradient(value);
|
||||
if (gradient) {
|
||||
if (gradient.color.length === 0) return 0;
|
||||
|
||||
const first = contrast(gradient.color[0]);
|
||||
const last = contrast(gradient.color[gradient.color.length - 1]);
|
||||
|
||||
return Math.min(first, last);
|
||||
}
|
||||
|
||||
return contrast(fillChoiceUIColor(value));
|
||||
}
|
||||
|
||||
// GRADIENT UTILITY FUNCTIONS
|
||||
|
||||
export function isGradientUI(value: unknown): value is GradientUI {
|
||||
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value;
|
||||
}
|
||||
|
||||
// FILL CHOICE UTILITY FUNCTIONS
|
||||
|
||||
export function fillChoiceUIColor(value: FillChoiceUI): SRGBA8 | undefined {
|
||||
if (typeof value === "object" && "Solid" in value) return value.Solid;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function fillChoiceUIGradient(value: FillChoiceUI): GradientUI | undefined {
|
||||
if (typeof value === "object" && "Gradient" in value) return value.Gradient;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseFillChoiceUI(value: unknown): FillChoiceUI {
|
||||
if (value === "None" || value === undefined || value === null) return "None";
|
||||
if (typeof value === "object" && value !== null && "Solid" in value && isSRgba8(value.Solid)) return { Solid: value.Solid };
|
||||
if (typeof value === "object" && value !== null && "Gradient" in value && isGradientUI(value.Gradient)) return { Gradient: value.Gradient };
|
||||
return "None";
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { browserVersion, operatingSystem } from "/src/utility-functions/platform";
|
||||
import { stripIndents } from "/src/utility-functions/strip-indents";
|
||||
|
||||
export function crashReportUrl(panicDetails: string): string {
|
||||
const url = new URL("https://github.com/GraphiteEditor/Graphite/issues/new");
|
||||
|
||||
const buildUrl = (includeCrashReport: boolean) => {
|
||||
let body = stripIndents`
|
||||
**Describe the Crash**
|
||||
Explain clearly what you were doing when the crash occurred.
|
||||
|
||||
**Steps To Reproduce**
|
||||
Describe precisely how the crash occurred, step by step, starting with a new editor window.
|
||||
1. Open the Graphite editor at https://dev.graphite.art — IMPORTANT! Confirm you have tested in this development version. It may have already been fixed since the last stable release.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
5.
|
||||
|
||||
**Additional Details**
|
||||
Provide any further information or context that you think would be helpful in fixing the issue. Screenshots or video can be linked or attached to this issue.
|
||||
|
||||
**Browser and OS**
|
||||
${browserVersion()}, ${operatingSystem()}
|
||||
|
||||
**Stack Trace**
|
||||
Copied from the crash dialog in the Graphite editor:
|
||||
`;
|
||||
|
||||
const manualCopyStackTraceNotice = stripIndents`
|
||||
Before submitting this bug, REPLACE THIS WITH THE LOG. Return to the editor and click "Copy Error Log" in the crash dialog and paste it in place of this text.
|
||||
`;
|
||||
|
||||
body += "\n\n```\n";
|
||||
body += includeCrashReport ? panicDetails.trimEnd() : manualCopyStackTraceNotice;
|
||||
body += "\n```";
|
||||
|
||||
const fields = {
|
||||
title: "[Crash Report] ",
|
||||
body,
|
||||
labels: ["Crash"].join(","),
|
||||
projects: [].join(","),
|
||||
milestone: "",
|
||||
assignee: "",
|
||||
template: "",
|
||||
};
|
||||
|
||||
Object.entries(fields).forEach(([field, value]) => {
|
||||
if (value) url.searchParams.set(field, value);
|
||||
});
|
||||
|
||||
return String(url);
|
||||
};
|
||||
|
||||
let urlString = buildUrl(true);
|
||||
if (urlString.length >= 8192) {
|
||||
// Fall back to a shorter version if it exceeds GitHub limits of 8192 total characters
|
||||
urlString = buildUrl(false);
|
||||
}
|
||||
return urlString;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable no-useless-escape */
|
||||
/* eslint-disable quotes */
|
||||
|
||||
export function escapeJSON(str: string): string {
|
||||
return str
|
||||
.replace(/[\\]/g, "\\\\")
|
||||
.replace(/[\"]/g, '\\"')
|
||||
.replace(/[\/]/g, "\\/")
|
||||
.replace(/[\b]/g, "\\b")
|
||||
.replace(/[\f]/g, "\\f")
|
||||
.replace(/[\n]/g, "\\n")
|
||||
.replace(/[\r]/g, "\\r")
|
||||
.replace(/[\t]/g, "\\t");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { extractPixelData } from "/src/utility-functions/rasterization";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export function downloadFileURL(filename: string, url: string) {
|
||||
const element = document.createElement("a");
|
||||
|
||||
element.href = url;
|
||||
element.setAttribute("download", filename);
|
||||
|
||||
element.click();
|
||||
}
|
||||
|
||||
export function downloadFileBlob(filename: string, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
downloadFileURL(filename, url);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function downloadFile(filename: string, content: Uint8Array) {
|
||||
const type = filename.endsWith(".svg") ? "image/svg+xml;charset=utf-8" : "application/octet-stream";
|
||||
|
||||
if (content.length > 0 && content.buffer instanceof ArrayBuffer) {
|
||||
const contentView = new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
|
||||
const blob = new Blob([contentView], { type });
|
||||
downloadFileBlob(filename, blob);
|
||||
}
|
||||
}
|
||||
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#accept for the `accept` string format
|
||||
export async function upload(accept: string, textOrData: "text"): Promise<UploadResult<string>>;
|
||||
export async function upload(accept: string, textOrData: "data"): Promise<UploadResult<Uint8Array>>;
|
||||
export async function upload(accept: string, textOrData: "both"): Promise<UploadResult<{ text: string; data: Uint8Array }>>;
|
||||
export async function upload(accept: string, textOrData: "data", multiple: true): Promise<UploadResult<Uint8Array>[]>;
|
||||
export async function upload(
|
||||
accept: string,
|
||||
textOrData: "text" | "data" | "both",
|
||||
multiple = false,
|
||||
): Promise<UploadResult<string | Uint8Array | { text: string; data: Uint8Array }> | UploadResult<Uint8Array>[]> {
|
||||
return new Promise((resolve) => {
|
||||
const element = document.createElement("input");
|
||||
element.type = "file";
|
||||
element.accept = accept;
|
||||
element.multiple = multiple;
|
||||
|
||||
element.addEventListener(
|
||||
"change",
|
||||
async () => {
|
||||
if (!element.files?.length) return;
|
||||
|
||||
// The `multiple: true` overload constrains `textOrData` to "data", so we know each file produces a Uint8Array
|
||||
if (multiple) {
|
||||
const results = await Promise.all(
|
||||
Array.from(element.files).map(async (file) => ({
|
||||
filename: file.name,
|
||||
type: file.type,
|
||||
content: new Uint8Array(await file.arrayBuffer()),
|
||||
})),
|
||||
);
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = element.files[0];
|
||||
const content =
|
||||
textOrData === "text"
|
||||
? await file.text()
|
||||
: textOrData === "data"
|
||||
? new Uint8Array(await file.arrayBuffer())
|
||||
: { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) };
|
||||
resolve({ filename: file.name, type: file.type, content });
|
||||
},
|
||||
{ capture: false, once: true },
|
||||
);
|
||||
|
||||
element.click();
|
||||
|
||||
// Once `element` goes out of scope, it has no references so it gets garbage collected along with its event listener, so `removeEventListener` is not needed
|
||||
});
|
||||
}
|
||||
export type UploadResult<T> = { filename: string; type: string; content: T };
|
||||
|
||||
export async function pasteFile(item: DataTransferItem, editor: EditorWrapper, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
if (file.type.startsWith("image/svg")) {
|
||||
const svg = await file.text();
|
||||
editor.pasteSvg(file.name, svg, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
|
||||
} else if (file.type.startsWith("image/")) {
|
||||
const imageData = await extractPixelData(file);
|
||||
editor.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
|
||||
} else if (file.name.endsWith("." + editor.fileExtension())) {
|
||||
// TODO: When we eventually have sub-documents, this should be changed to import the document as a node instead of opening it in a separate tab
|
||||
editor.openFile(file.name, await file.bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Demo artwork
|
||||
import ThumbnailChangingSeasons from "/assets/thumbnail-changing-seasons.png";
|
||||
import ThumbnailIsometricFountain from "/assets/thumbnail-isometric-fountain.png";
|
||||
import ThumbnailPaintedDreams from "/assets/thumbnail-painted-dreams.png";
|
||||
import ThumbnailParametricDunescape from "/assets/thumbnail-parametric-dunescape.png";
|
||||
import ThumbnailProceduralStringLights from "/assets/thumbnail-procedural-string-lights.png";
|
||||
import ThumbnailRedDress from "/assets/thumbnail-red-dress.png";
|
||||
import ThumbnailValleyOfSpires from "/assets/thumbnail-valley-of-spires.png";
|
||||
|
||||
const DEMO_ARTWORK = {
|
||||
ThumbnailChangingSeasons,
|
||||
ThumbnailIsometricFountain,
|
||||
ThumbnailPaintedDreams,
|
||||
ThumbnailParametricDunescape,
|
||||
ThumbnailProceduralStringLights,
|
||||
ThumbnailRedDress,
|
||||
ThumbnailValleyOfSpires,
|
||||
} as const;
|
||||
|
||||
// All images
|
||||
const IMAGE_LIST = {
|
||||
...DEMO_ARTWORK,
|
||||
} as const;
|
||||
|
||||
// Exported images and types
|
||||
export const IMAGES: ImageDefinitionType<typeof IMAGE_LIST> = IMAGE_LIST;
|
||||
export const IMAGE_BASE64_STRINGS = Object.fromEntries(Object.entries(IMAGES).map(([name, data]) => [name, data]));
|
||||
|
||||
// See `icons.ts` for explanation about how this works
|
||||
type EvaluateType<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
type ImageDefinitionType<T extends Record<string, string>> = EvaluateType<{ [key in keyof T]: string }>;
|
||||
@@ -0,0 +1,365 @@
|
||||
import { get } from "svelte/store";
|
||||
import type { DialogStore } from "/src/stores/dialog";
|
||||
import type { DocumentStore } from "/src/stores/document";
|
||||
import { toggleFullscreen } from "/src/stores/fullscreen";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import { pasteFile } from "/src/utility-functions/files";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry";
|
||||
import { operatingSystem } from "/src/utility-functions/platform";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const BUTTON_LEFT = 0;
|
||||
const BUTTON_MIDDLE = 1;
|
||||
const BUTTON_RIGHT = 2;
|
||||
const BUTTON_BACK = 3;
|
||||
const BUTTON_FORWARD = 4;
|
||||
|
||||
let viewportPointerInteractionOngoing = false;
|
||||
let textToolInteractiveInputElement: HTMLDivElement | undefined = undefined;
|
||||
let canvasFocused = true;
|
||||
let inPointerLock = false;
|
||||
let lastShakeTime = 0;
|
||||
const shakeSamples: { x: number; y: number; time: number }[] = [];
|
||||
|
||||
// Keyboard events
|
||||
|
||||
export async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent, dialogStore: DialogStore): Promise<boolean> {
|
||||
if (!dialogStore) return false;
|
||||
|
||||
// Don't redirect when a dialog is covering the workspace
|
||||
if (get(dialogStore).visible) return false;
|
||||
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
// TODO: Switch to a system where everything is sent to the backend, then the input preprocessor makes decisions and kicks some inputs back to the frontend
|
||||
const accelKey = operatingSystem() === "Mac" ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Cut, copy, and paste is handled in the backend on desktop
|
||||
if (import.meta.env.MODE === "native" && accelKey && ["KeyX", "KeyC", "KeyV"].includes(key)) return true;
|
||||
// But on web, we want to not redirect paste
|
||||
if (import.meta.env.MODE !== "native" && key === "KeyV" && accelKey) return false;
|
||||
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(accelKey && ["Enter", "NumpadEnter"].includes(key))) return false;
|
||||
|
||||
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
if (
|
||||
!canvasFocused &&
|
||||
!targetIsTextField(e.target || undefined) &&
|
||||
["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key) &&
|
||||
!(e.ctrlKey || e.metaKey || e.altKey)
|
||||
)
|
||||
return false;
|
||||
|
||||
// Don't redirect if a MenuList is open
|
||||
if (window.document.querySelector("[data-floating-menu-content]")) return false;
|
||||
|
||||
// Web-only keyboard shortcuts
|
||||
if (import.meta.env.MODE !== "native") {
|
||||
// Don't redirect a fullscreen request, but process it immediately instead
|
||||
if (((operatingSystem() !== "Mac" && key === "F11") || (operatingSystem() === "Mac" && e.ctrlKey && e.metaKey && key === "KeyF")) && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't redirect a reload request
|
||||
if (key === "F5") return false;
|
||||
if (key === "KeyR" && accelKey) return false;
|
||||
|
||||
// Don't redirect debugging tools
|
||||
if (["F12", "F8"].includes(key)) return false;
|
||||
if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false;
|
||||
}
|
||||
|
||||
// Redirect to the backend
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function onKeyDown(e: KeyboardEvent, editor: EditorWrapper, dialogStore: DialogStore) {
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"];
|
||||
if (e.repeat && NO_KEY_REPEAT_MODIFIER_KEYS.includes(key)) return;
|
||||
|
||||
if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.onKeyDown(key, modifiers, e.repeat);
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(dialogStore).visible && key === "Escape") {
|
||||
editor.onDialogDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
export async function onKeyUp(e: KeyboardEvent, editor: EditorWrapper, dialogStore: DialogStore) {
|
||||
const key = await getLocalizedScanCode(e);
|
||||
|
||||
if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.onKeyUp(key, modifiers, e.repeat);
|
||||
}
|
||||
}
|
||||
|
||||
// Pointer events
|
||||
|
||||
// While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend
|
||||
export function onPointerMove(e: PointerEvent, editor: EditorWrapper, documentStore: DocumentStore) {
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
// Don't redirect pointer movement to the backend if there's no ongoing interaction and it's over a floating menu, or the graph overlay, on top of the canvas
|
||||
// TODO: A better approach is to pass along a boolean to the backend's input preprocessor so it can know if it's being occluded by the GUI.
|
||||
// TODO: This would allow it to properly decide to act on removing hover focus from something that was hovered in the canvas before moving over the GUI.
|
||||
// TODO: Further explanation: https://github.com/GraphiteEditor/Graphite/pull/623#discussion_r866436197
|
||||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
const inGraphOverlay = get(documentStore).graphViewOverlayOpen;
|
||||
if (!viewportPointerInteractionOngoing && (inFloatingMenu || inGraphOverlay)) return;
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
if (detectShake(e)) editor.onMouseShake(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
editor.onMouseMove(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
export function onPointerDown(e: PointerEvent, editor: EditorWrapper, dialogStore: DialogStore) {
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]");
|
||||
const isTargetingCanvas = !inFloatingMenu && e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
const inDialog = e.target instanceof Element && e.target.closest("[data-dialog] [data-floating-menu-content]");
|
||||
const inContextMenu = e.target instanceof Element && e.target.closest("[data-context-menu]");
|
||||
const inTextInput = e.target === textToolInteractiveInputElement;
|
||||
|
||||
if (get(dialogStore).visible && !inDialog) {
|
||||
editor.onDialogDismiss();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (!inTextInput && !inContextMenu) {
|
||||
if (textToolInteractiveInputElement) {
|
||||
const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT;
|
||||
editor.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick);
|
||||
} else {
|
||||
viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
|
||||
}
|
||||
}
|
||||
|
||||
if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) {
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
export function onPointerUp(e: PointerEvent, editor: EditorWrapper) {
|
||||
potentiallyRestoreCanvasFocus(e);
|
||||
|
||||
// Don't let the browser navigate back or forward when using the buttons on some mice
|
||||
// TODO: This works in Chrome but not in Firefox
|
||||
// TODO: Possible workaround: use the browser's history API to block navigation:
|
||||
// TODO: <https://stackoverflow.com/questions/57102502/preventing-mouse-fourth-and-fifth-buttons-from-navigating-back-forward-in-browse>
|
||||
if (e.button === BUTTON_BACK || e.button === BUTTON_FORWARD) e.preventDefault();
|
||||
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
if (textToolInteractiveInputElement) return;
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
|
||||
// Mouse events
|
||||
|
||||
export function onPotentialDoubleClick(e: MouseEvent, editor: EditorWrapper) {
|
||||
if (textToolInteractiveInputElement || inPointerLock) return;
|
||||
|
||||
// Allow only events within the viewport or node graph boundaries
|
||||
const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
if (!(isTargetingCanvas instanceof Element)) return;
|
||||
|
||||
// Allow only repeated increments of double-clicks (not 1, 3, 5, etc.)
|
||||
if (e.detail % 2 == 1) return;
|
||||
|
||||
// `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead
|
||||
let buttons = 1;
|
||||
if (e.button === BUTTON_LEFT) buttons = 1; // Left
|
||||
if (e.button === BUTTON_RIGHT) buttons = 2; // Right
|
||||
if (e.button === BUTTON_MIDDLE) buttons = 4; // Middle
|
||||
if (e.button === BUTTON_BACK) buttons = 8; // Back
|
||||
if (e.button === BUTTON_FORWARD) buttons = 16; // Forward
|
||||
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.onDoubleClick(e.clientX, e.clientY, buttons, modifiers);
|
||||
}
|
||||
|
||||
export function onMouseDown(e: MouseEvent) {
|
||||
// Block middle mouse button auto-scroll mode (the circular gizmo that appears and allows quick scrolling by moving the cursor above or below it)
|
||||
if (e.button === BUTTON_MIDDLE) e.preventDefault();
|
||||
}
|
||||
|
||||
export function onContextMenu(e: MouseEvent) {
|
||||
if (!targetIsTextField(e.target || undefined) && e.target !== textToolInteractiveInputElement) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function onPointerLockChange() {
|
||||
inPointerLock = Boolean(window.document.pointerLockElement);
|
||||
}
|
||||
|
||||
// Wheel events
|
||||
|
||||
export function onWheelScroll(e: WheelEvent, editor: EditorWrapper) {
|
||||
const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]");
|
||||
|
||||
// Prevent zooming the entire page when using Ctrl + scroll wheel outside of the viewport
|
||||
if (e.ctrlKey && !isTargetingCanvas) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element
|
||||
// There seems to be no possible way to properly employ the browser's smooth scrolling interpolation
|
||||
const horizontalScrollableElement = e.target instanceof Element && !e.target.closest("[data-scrollable-y]") && e.target.closest("[data-scrollable-x]");
|
||||
if (horizontalScrollableElement && e.deltaY !== 0) {
|
||||
horizontalScrollableElement.scrollTo(horizontalScrollableElement.scrollLeft + e.deltaY, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetingCanvas) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.onWheelScroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
// Receives a custom event dispatched when the user begins interactively editing with the text tool.
|
||||
// We keep a copy of the text input element to check against when it's active for text entry.
|
||||
export function onModifyInputField(e: CustomEvent) {
|
||||
textToolInteractiveInputElement = e.detail;
|
||||
}
|
||||
|
||||
// Window events
|
||||
|
||||
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorWrapper, portfolioStore: PortfolioStore) {
|
||||
const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex];
|
||||
if (activeDocument) editor.triggerAutoSave(activeDocument.id);
|
||||
|
||||
// Skip the message if the editor crashed, since work is already lost
|
||||
if (await editor.hasCrashed()) return;
|
||||
|
||||
// Skip the message during development, since it's annoying when testing
|
||||
if (await editor.inDevelopmentMode()) return;
|
||||
|
||||
const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.is_saved, true);
|
||||
if (!allDocumentsSaved) {
|
||||
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function onPaste(e: ClipboardEvent, editor: EditorWrapper) {
|
||||
const dataTransfer = e.clipboardData;
|
||||
if (!dataTransfer || targetIsTextField(e.target || undefined)) return;
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach(async (item) => {
|
||||
if (item.type === "text/plain") item.getAsString((text) => editor.pasteText(text));
|
||||
await pasteFile(item, editor);
|
||||
});
|
||||
}
|
||||
|
||||
export function onFocusOut() {
|
||||
canvasFocused = false;
|
||||
}
|
||||
|
||||
function detectShake(e: PointerEvent | MouseEvent): boolean {
|
||||
const SENSITIVITY_DIRECTION_CHANGES = 3;
|
||||
const SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO = 0.1;
|
||||
const DETECTION_WINDOW_MS = 500;
|
||||
const DEBOUNCE_MS = 1000;
|
||||
|
||||
// Add the current mouse position and time to our list of samples
|
||||
const now = Date.now();
|
||||
shakeSamples.push({ x: e.clientX, y: e.clientY, time: now });
|
||||
|
||||
// Remove samples that are older than our time window
|
||||
while (shakeSamples.length > 0 && now - shakeSamples[0].time > DETECTION_WINDOW_MS) {
|
||||
shakeSamples.shift();
|
||||
}
|
||||
|
||||
// We can't be shaking if it's too early in terms of samples or debounce time
|
||||
if (shakeSamples.length <= 3 || now - lastShakeTime <= DEBOUNCE_MS) return false;
|
||||
|
||||
// Calculate the total distance traveled
|
||||
let totalDistanceSquared = 0;
|
||||
for (let i = 1; i < shakeSamples.length; i += 1) {
|
||||
const p1 = shakeSamples[i - 1];
|
||||
const p2 = shakeSamples[i];
|
||||
totalDistanceSquared += (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||
}
|
||||
|
||||
// Count the number of times the mouse changes direction significantly, and the average position of the mouse
|
||||
let directionChanges = 0;
|
||||
const averagePoint = { x: 0, y: 0 };
|
||||
let averagePointCount = 0;
|
||||
for (let i = 0; i < shakeSamples.length - 2; i += 1) {
|
||||
const p1 = shakeSamples[i];
|
||||
const p2 = shakeSamples[i + 1];
|
||||
const p3 = shakeSamples[i + 2];
|
||||
|
||||
const vector1 = { x: p2.x - p1.x, y: p2.y - p1.y };
|
||||
const vector2 = { x: p3.x - p2.x, y: p3.y - p2.y };
|
||||
|
||||
// Check if the dot product is negative, which indicates the angle between vectors is > 90 degrees
|
||||
if (vector1.x * vector2.x + vector1.y * vector2.y < 0) directionChanges += 1;
|
||||
|
||||
averagePoint.x += p2.x;
|
||||
averagePoint.y += p2.y;
|
||||
averagePointCount += 1;
|
||||
}
|
||||
if (averagePointCount > 0) {
|
||||
averagePoint.x /= averagePointCount;
|
||||
averagePoint.y /= averagePointCount;
|
||||
}
|
||||
|
||||
// Calculate the displacement (the distance between the first and last mouse positions)
|
||||
const lastPoint = shakeSamples[shakeSamples.length - 1];
|
||||
const displacementSquared = (lastPoint.x - averagePoint.x) ** 2 + (lastPoint.y - averagePoint.y) ** 2;
|
||||
|
||||
// A shake is detected if the mouse has traveled a lot but not moved far, and has changed direction enough times
|
||||
if (SENSITIVITY_DISTANCE_TO_DISPLACEMENT_RATIO * totalDistanceSquared >= displacementSquared && directionChanges >= SENSITIVITY_DIRECTION_CHANGES) {
|
||||
lastShakeTime = now;
|
||||
shakeSamples.length = 0;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function targetIsTextField(target: EventTarget | HTMLElement | undefined): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLInputElement && ["text", "password", "email", "url", "tel", "search", "number", "date", "datetime-local", "month", "time", "week"].includes(target.type))
|
||||
);
|
||||
}
|
||||
|
||||
function potentiallyRestoreCanvasFocus(e: Event) {
|
||||
const appElement = window.document.querySelector("[data-app-container]");
|
||||
const app = appElement instanceof HTMLElement ? appElement : null;
|
||||
|
||||
const newInCanvasArea =
|
||||
(e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-graph]")) instanceof Element &&
|
||||
!targetIsTextField(window.document.activeElement || undefined);
|
||||
if (!canvasFocused && newInCanvasArea) {
|
||||
canvasFocused = true;
|
||||
app?.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
export function makeKeyboardModifiersBitfield(e: WheelEvent | PointerEvent | MouseEvent | KeyboardEvent): number {
|
||||
return (
|
||||
// Shift (all platforms)
|
||||
(Number(e.shiftKey) << 0) |
|
||||
// Alt (all platforms, also called Option on Mac)
|
||||
(Number(e.altKey) << 1) |
|
||||
// Control (all platforms)
|
||||
(Number(e.ctrlKey) << 2) |
|
||||
// Meta (Windows/Linux) or Command (Mac)
|
||||
(Number(e.metaKey) << 3)
|
||||
);
|
||||
}
|
||||
|
||||
// Necessary because innerText puts an extra newline character at the end when the text is more than one line.
|
||||
export function textInputCleanup(text: string): string {
|
||||
if (text[text.length - 1] === "\n") return text.slice(0, -1);
|
||||
return text;
|
||||
}
|
||||
|
||||
// This function tries to find what scan code the user pressed, even if using a non-US keyboard.
|
||||
// Directly using `KeyboardEvent.code` scan code only works on a US QWERTY layout, because alternate layouts like
|
||||
// QWERTZ (German) or AZERTY (French) will end up reporting the wrong keys.
|
||||
// Directly using `KeyboardEvent.key` doesn't work because the results are often garbage, as the printed character
|
||||
// varies when the Shift key is pressed, or worse, when the Option (Alt) key on a Mac is pressed.
|
||||
// This function does its best to try and sort through both of those sources of information to determine the localized scan code.
|
||||
//
|
||||
// This function is an imperfect stopgap solution to allow non-US keyboards to be handled on a best-effort basis.
|
||||
// Eventually we will need a more robust system based on a giant database of keyboard layouts from all around the world.
|
||||
// We'd provide the user a choice of layout, and aim to detect a default based on the `key` and `code` values entered by the user
|
||||
// combined with `Keyboard.getLayoutMap()` where supported in Chromium-based browsers and perhaps the browser's language and IP address.
|
||||
// We are also limited by browser APIs, since the spec doesn't support what we need it to:
|
||||
// <https://github.com/WICG/keyboard-map/issues/26>
|
||||
// In the desktop version of VS Code, this is achieved with this Electron plugin:
|
||||
// <https://github.com/Microsoft/node-native-keymap>
|
||||
// We may be able to port that (it's a relatively small codebase) to Rust for use with our desktop application.
|
||||
// But on the web, just like VS Code, we're limited by the shortcomings of the spec.
|
||||
// A collection of further insights:
|
||||
// <https://docs.google.com/document/d/1p17IBbYGsZivLIMhKZOaCJFAJFokbPfKrkB37fOPXSM/edit>
|
||||
// And it's a really good idea to read the explainer on keyboard layout variations and the whole spec (it's quite digestible):
|
||||
// <https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system>
|
||||
export async function getLocalizedScanCode(e: KeyboardEvent): Promise<string> {
|
||||
const keyText = e.key;
|
||||
const scanCode = e.code;
|
||||
|
||||
// Use the key code directly if it isn't one that changes per locale (i.e. isn't a writing system key or one of the other few exceptions)
|
||||
const scanCodeNotLocaleSpecific = !LOCALE_SPECIFIC_KEY_CODES.includes(scanCode);
|
||||
if (scanCodeNotLocaleSpecific) {
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
// Use the key directly if it's one of the exceptions that usually don't change, but sometimes do in a predictable way
|
||||
if (SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE.includes(scanCode)) {
|
||||
// Numpad comma and period which swap in some locales as decimal and thousands separator symbols
|
||||
if (NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS.includes(scanCode)) {
|
||||
switch (scanCode) {
|
||||
case ".":
|
||||
return "NumpadDecimal";
|
||||
case ",":
|
||||
return "NumpadComma";
|
||||
default:
|
||||
return scanCode;
|
||||
}
|
||||
}
|
||||
|
||||
// The AltRight key changes from a key value of "Alt" to "AltGraph" on keyboards with an AltGraph key
|
||||
if (scanCode === "AltRight") {
|
||||
return keyText === "Alt" ? "AltRight" : "AltGraph";
|
||||
}
|
||||
}
|
||||
|
||||
// Use good-enough-for-now heuristics on the writing system keys, which are commonly subject to change by locale
|
||||
|
||||
// Number scan codes
|
||||
if (/^Digit[0-9]$/.test(scanCode)) {
|
||||
// For now it's good enough to treat every digit key, regardless of locale, as just its digit from the standard US layout.
|
||||
// Even on a keyboard like the French AZERTY layout, where numbers are shifted, users still refer to those keys by their numbers.
|
||||
// This unfortunately means that any special symbols under these keys are overridden by their number, making it impossible to access some shortcuts that rely on those special symbols.
|
||||
// We'll have to deal with that for now, and find a way to upgrade or properly replace this system, or assign alternate keymaps based on locale, when people complain.
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
// Letter scan codes
|
||||
if (/^Key([A-Z])$/.test(scanCode)) {
|
||||
// Get the uppercase letter, with any accents or discritics removed if possible
|
||||
const rawLetter = keyText
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toUpperCase();
|
||||
|
||||
// If the key letter is in the A-Z range, use the key code for that letter
|
||||
if (/^[A-Z]$/.test(rawLetter)) return `Key${rawLetter}`;
|
||||
|
||||
// If the key text isn't one of the named attribute values, that means it must be the literal unicode value which we use directly
|
||||
// It is likely a weird symbol that isn't in the A-Z range even with accents removed.
|
||||
// It might be a symbol from an Option key combination on a Mac. Or it might be from a non-Latin alphabet like Cyrillic.
|
||||
if (!KEY_ATTRIBUTE_VALUES.has(keyText)) {
|
||||
if (navigator.keyboard && "getLayoutMap" in navigator.keyboard) {
|
||||
const layout = await navigator.keyboard.getLayoutMap();
|
||||
|
||||
type KeyCode = string;
|
||||
type KeySymbol = string;
|
||||
// Get all the keyboard mappings and transform the key symbols to uppercase
|
||||
const keyboardLayoutMap: [KeyCode, KeySymbol][] = [...layout.entries()].map(([keyCode, keySymbol]) => [keyCode, keySymbol.toUpperCase()]);
|
||||
|
||||
// If we match the uppercase version of the pressed key character, use the scan code that produces it
|
||||
const matchedEntry = keyboardLayoutMap.find(([_, keySymbol]) => keySymbol === keyText.toUpperCase());
|
||||
if (matchedEntry) return matchedEntry[0];
|
||||
}
|
||||
|
||||
// If the keyboard layout API is unavailable, or it didn't match anything, just return the scan code that the user typed
|
||||
// This isn't perfect because alternate keyboard layouts may end up having the US QWERTY key,
|
||||
// but it's all we can do without a giant database of keyboard layouts and Mac Option key combinations
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
// If the key's named attribute value shares a name with a scan code, use that scan code
|
||||
if (KEY_CODE_NAMES.includes(keyText)) {
|
||||
return scanCodeFromKeyText(keyText);
|
||||
}
|
||||
if (KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS.includes(keyText)) {
|
||||
// Since for some reason we're in a situation where we are using the key instead of the scan code to
|
||||
// match one of the modifier keys which have both a Left and Right variant as part of the scan code names,
|
||||
// but no handedness as part of the key's named attribute values, we default to the left side as it's more common.
|
||||
return `${keyText}Left`;
|
||||
}
|
||||
|
||||
// All reasonable attempts to figure out what this key is has now failed, so we fall back on the US QWERTY layout scan code
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
// If the key text is the unicode character for one of the standard symbols on the US keyboard, use the symbol even though it's not located on the same scan code as on a US keyboard
|
||||
if (WRITING_SYSTEM_SPECIAL_CHARS.includes(keyText)) {
|
||||
return scanCodeFromKeyText(keyText);
|
||||
}
|
||||
|
||||
// If the key is otherwise totally unrecognized, we ignore it
|
||||
if (keyText === "Unidentified" || scanCode === "Unidentified") return "Unidentified";
|
||||
|
||||
// As a last resort, we just use the scan code
|
||||
return scanCode;
|
||||
}
|
||||
|
||||
function scanCodeFromKeyText(keyText: string): string {
|
||||
// There are many possible unicode symbols as well as named attribute values, but we only care about finding the equivalent scan code (based on the US keyboard) without regard for modifiers
|
||||
|
||||
// Match any handed modifier keys by claiming it's the left-handed modifier
|
||||
if (KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS.includes(keyText)) {
|
||||
return `${keyText}Left`;
|
||||
}
|
||||
|
||||
// Match any named attribute keys to an identical key name
|
||||
const identicalName = KEY_CODE_NAMES.find((code) => code === keyText);
|
||||
if (identicalName) return keyText;
|
||||
|
||||
// Match the space character
|
||||
// Order matters because the next step assumes it can safely ignore the space character
|
||||
if (keyText === " ") return "Space";
|
||||
|
||||
// Match individual characters by the scan code which produces that symbol on a US keyboard, either shifted or not
|
||||
// This also includes the `Digit*` and `Key*` codes
|
||||
const matchedScanCode = KEY_CODES.find((info) => info.keys?.us?.includes(keyText));
|
||||
if (matchedScanCode) return matchedScanCode.code;
|
||||
|
||||
return "Unidentified";
|
||||
}
|
||||
|
||||
type KeyCategories = "writing-system" | "functional" | "functional-jp-kr" | "control-pad" | "arrow-pad" | "numpad" | "function" | "media" | "unidentified";
|
||||
type KeyboardLocale = "us";
|
||||
type ScanCodeInfo = { code: string; category: KeyCategories; keys?: Record<KeyboardLocale, string | undefined> };
|
||||
const KEY_CODES: ScanCodeInfo[] = [
|
||||
// Writing system keys
|
||||
// Codes produce different printed characters depending on locale
|
||||
// https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system
|
||||
|
||||
{ code: "Digit0", category: "writing-system", keys: { us: "0 )" } },
|
||||
{ code: "Digit1", category: "writing-system", keys: { us: "1 !" } },
|
||||
{ code: "Digit2", category: "writing-system", keys: { us: "2 @" } },
|
||||
{ code: "Digit3", category: "writing-system", keys: { us: "3 #" } },
|
||||
{ code: "Digit4", category: "writing-system", keys: { us: "4 $" } },
|
||||
{ code: "Digit5", category: "writing-system", keys: { us: "5 %" } },
|
||||
{ code: "Digit6", category: "writing-system", keys: { us: "6 ^" } },
|
||||
{ code: "Digit7", category: "writing-system", keys: { us: "7 &" } },
|
||||
{ code: "Digit8", category: "writing-system", keys: { us: "8 *" } },
|
||||
{ code: "Digit9", category: "writing-system", keys: { us: "9 (" } },
|
||||
|
||||
{ code: "KeyA", category: "writing-system", keys: { us: "a A" } },
|
||||
{ code: "KeyB", category: "writing-system", keys: { us: "b B" } },
|
||||
{ code: "KeyC", category: "writing-system", keys: { us: "c C" } },
|
||||
{ code: "KeyD", category: "writing-system", keys: { us: "d D" } },
|
||||
{ code: "KeyE", category: "writing-system", keys: { us: "e E" } },
|
||||
{ code: "KeyF", category: "writing-system", keys: { us: "f F" } },
|
||||
{ code: "KeyG", category: "writing-system", keys: { us: "g G" } },
|
||||
{ code: "KeyH", category: "writing-system", keys: { us: "h H" } },
|
||||
{ code: "KeyI", category: "writing-system", keys: { us: "i I" } },
|
||||
{ code: "KeyJ", category: "writing-system", keys: { us: "j J" } },
|
||||
{ code: "KeyK", category: "writing-system", keys: { us: "k K" } },
|
||||
{ code: "KeyL", category: "writing-system", keys: { us: "l L" } },
|
||||
{ code: "KeyM", category: "writing-system", keys: { us: "m M" } },
|
||||
{ code: "KeyN", category: "writing-system", keys: { us: "n N" } },
|
||||
{ code: "KeyO", category: "writing-system", keys: { us: "o O" } },
|
||||
{ code: "KeyP", category: "writing-system", keys: { us: "p P" } },
|
||||
{ code: "KeyQ", category: "writing-system", keys: { us: "q Q" } },
|
||||
{ code: "KeyR", category: "writing-system", keys: { us: "r R" } },
|
||||
{ code: "KeyS", category: "writing-system", keys: { us: "s S" } },
|
||||
{ code: "KeyT", category: "writing-system", keys: { us: "t T" } },
|
||||
{ code: "KeyU", category: "writing-system", keys: { us: "u U" } },
|
||||
{ code: "KeyV", category: "writing-system", keys: { us: "v V" } },
|
||||
{ code: "KeyW", category: "writing-system", keys: { us: "w W" } },
|
||||
{ code: "KeyX", category: "writing-system", keys: { us: "x X" } },
|
||||
{ code: "KeyY", category: "writing-system", keys: { us: "y Y" } },
|
||||
{ code: "KeyZ", category: "writing-system", keys: { us: "z Z" } },
|
||||
|
||||
{ code: "Backquote", category: "writing-system", keys: { us: "` ~" } },
|
||||
{ code: "Backslash", category: "writing-system", keys: { us: "\\ |" } },
|
||||
{ code: "BracketLeft", category: "writing-system", keys: { us: "[ {" } },
|
||||
{ code: "BracketRight", category: "writing-system", keys: { us: "] }" } },
|
||||
{ code: "Comma", category: "writing-system", keys: { us: ", <" } },
|
||||
{ code: "Equal", category: "writing-system", keys: { us: "= +" } },
|
||||
{ code: "Minus", category: "writing-system", keys: { us: "- _" } },
|
||||
{ code: "Period", category: "writing-system", keys: { us: ". >" } },
|
||||
{ code: "Quote", category: "writing-system", keys: { us: "' \"" } },
|
||||
{ code: "Semicolon", category: "writing-system", keys: { us: "; :" } },
|
||||
{ code: "Slash", category: "writing-system", keys: { us: "/ ?" } },
|
||||
|
||||
{ code: "IntlBackslash", category: "writing-system", keys: { us: undefined } },
|
||||
{ code: "IntlRo", category: "writing-system", keys: { us: undefined } },
|
||||
{ code: "IntlYen", category: "writing-system", keys: { us: undefined } },
|
||||
|
||||
// Functional keys
|
||||
// https://www.w3.org/TR/uievents-code/#key-alphanumeric-functional
|
||||
// Codes have the same meaning regardless of locale, except for "AltRight"
|
||||
{ code: "AltLeft", category: "functional" },
|
||||
{ code: "AltRight", category: "functional" }, // Exception: `key` value is either "Alt" or "AltGraph" depending on locale (e.g. US vs. French, respectively)
|
||||
// The W3C table includes this in the Writing System Keys table instead of the Functional Keys table, but its diagrams
|
||||
// and text describe it as a functional key, so it has been moved here under the assumption that the table is incorrect
|
||||
// https://github.com/w3c/uievents-code/issues/34
|
||||
{ code: "Backspace", category: "writing-system" }, // Shares a name with a key attribute
|
||||
{ code: "CapsLock", category: "functional" }, // Shares a name with a key attribute
|
||||
{ code: "ContextMenu", category: "functional" }, // Shares a name with a key attribute
|
||||
{ code: "ControlLeft", category: "functional" }, // Shares a name with a key attribute as "Control"
|
||||
{ code: "ControlRight", category: "functional" }, // Shares a name with a key attribute as "Control"
|
||||
{ code: "Enter", category: "functional" }, // Shares a name with a key attribute
|
||||
{ code: "MetaLeft", category: "functional" }, // Shares a name with a key attribute as "Meta"
|
||||
{ code: "MetaRight", category: "functional" }, // Shares a name with a key attribute as "Meta"
|
||||
{ code: "ShiftLeft", category: "functional" }, // Shares a name with a key attribute as "Shift"
|
||||
{ code: "ShiftRight", category: "functional" }, // Shares a name with a key attribute as "Shift"
|
||||
{ code: "Space", category: "functional" },
|
||||
{ code: "Tab", category: "functional" }, // Shares a name with a key attribute
|
||||
|
||||
// Functional Japanese/Korean keys
|
||||
{ code: "Convert", category: "functional-jp-kr" }, // Shares a name with a key attribute
|
||||
{ code: "KanaMode", category: "functional-jp-kr" }, // Shares a name with a key attribute
|
||||
{ code: "Lang1", category: "functional-jp-kr" },
|
||||
{ code: "Lang2", category: "functional-jp-kr" },
|
||||
{ code: "Lang3", category: "functional-jp-kr" },
|
||||
{ code: "Lang4", category: "functional-jp-kr" },
|
||||
{ code: "Lang5", category: "functional-jp-kr" },
|
||||
{ code: "NonConvert", category: "functional-jp-kr" }, // Shares a name with a key attribute
|
||||
|
||||
// Control pad keys
|
||||
{ code: "Delete", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "End", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "Help", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "Home", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "Insert", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "PageDown", category: "control-pad" }, // Shares a name with a key attribute
|
||||
{ code: "PageUp", category: "control-pad" }, // Shares a name with a key attribute
|
||||
|
||||
// Arrow pad keys
|
||||
{ code: "ArrowDown", category: "arrow-pad" }, // Shares a name with a key attribute
|
||||
{ code: "ArrowLeft", category: "arrow-pad" }, // Shares a name with a key attribute
|
||||
{ code: "ArrowRight", category: "arrow-pad" }, // Shares a name with a key attribute
|
||||
{ code: "ArrowUp", category: "arrow-pad" }, // Shares a name with a key attribute
|
||||
|
||||
// Numpad keys
|
||||
{ code: "Numpad0", category: "numpad" },
|
||||
{ code: "Numpad1", category: "numpad" },
|
||||
{ code: "Numpad2", category: "numpad" },
|
||||
{ code: "Numpad3", category: "numpad" },
|
||||
{ code: "Numpad4", category: "numpad" },
|
||||
{ code: "Numpad5", category: "numpad" },
|
||||
{ code: "Numpad6", category: "numpad" },
|
||||
{ code: "Numpad7", category: "numpad" },
|
||||
{ code: "Numpad8", category: "numpad" },
|
||||
{ code: "Numpad9", category: "numpad" },
|
||||
{ code: "NumLock", category: "numpad" }, // Shares a name with a key attribute
|
||||
{ code: "NumpadAdd", category: "numpad" },
|
||||
{ code: "NumpadBackspace", category: "numpad" },
|
||||
{ code: "NumpadClear", category: "numpad" },
|
||||
{ code: "NumpadClearEntry", category: "numpad" },
|
||||
{ code: "NumpadComma", category: "numpad" }, // Exception: Produces either a comma (,) or period (.) depending on locale (e.g. comma in US vs. period in Brazil)
|
||||
{ code: "NumpadDecimal", category: "numpad" }, // Exception: Produces either a comma (,) or period (.) depending on locale (e.g. period in US vs. decimal in Brazil)
|
||||
{ code: "NumpadDivide", category: "numpad" },
|
||||
{ code: "NumpadEnter", category: "numpad" },
|
||||
{ code: "NumpadEqual", category: "numpad" },
|
||||
{ code: "NumpadHash", category: "numpad" },
|
||||
{ code: "NumpadMemoryAdd", category: "numpad" },
|
||||
{ code: "NumpadMemoryClear", category: "numpad" },
|
||||
{ code: "NumpadMemoryRecall", category: "numpad" },
|
||||
{ code: "NumpadMemoryStore", category: "numpad" },
|
||||
{ code: "NumpadMemorySubtract", category: "numpad" },
|
||||
{ code: "NumpadMultiply", category: "numpad" },
|
||||
{ code: "NumpadParenLeft", category: "numpad" },
|
||||
{ code: "NumpadParenRight", category: "numpad" },
|
||||
{ code: "NumpadStar", category: "numpad" },
|
||||
{ code: "NumpadSubtract", category: "numpad" },
|
||||
|
||||
// Function keys
|
||||
{ code: "Escape", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F1", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F2", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F3", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F4", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F5", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F6", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F7", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F8", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F9", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F10", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F11", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F12", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F13", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F14", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F15", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F16", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F17", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F18", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F19", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F20", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F21", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F22", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F23", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "F24", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "Fn", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "FnLock", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "PrintScreen", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "ScrollLock", category: "function" }, // Shares a name with a key attribute
|
||||
{ code: "Pause", category: "function" }, // Shares a name with a key attribute
|
||||
|
||||
// Media keys
|
||||
{ code: "BrowserBack", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserFavorites", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserForward", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserHome", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserRefresh", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserSearch", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "BrowserStop", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "Eject", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "LaunchApp1", category: "media" },
|
||||
{ code: "LaunchApp2", category: "media" },
|
||||
{ code: "LaunchMail", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "MediaPlayPause", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "MediaSelect", category: "media" },
|
||||
{ code: "MediaStop", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "MediaTrackNext", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "MediaTrackPrevious", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "Power", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "Sleep", category: "media" },
|
||||
{ code: "AudioVolumeDown", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "AudioVolumeMute", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "AudioVolumeUp", category: "media" }, // Shares a name with a key attribute
|
||||
{ code: "WakeUp", category: "media" }, // Shares a name with a key attribute
|
||||
|
||||
// Unidentified keys
|
||||
{ code: "Unidentified", category: "unidentified" }, // Shares a name with a key attribute
|
||||
];
|
||||
const KEY_CODE_NAMES = Object.values(KEY_CODES).map((info) => info.code);
|
||||
// const KEY_CODE_NAMES_WITHOUT_HANDEDNESS = KEY_CODE_NAMES.filter((code) => !(code.endsWith("Right") && HANDED_KEY_ATTRIBUTE_VALUES.some((modifier) => code === `${modifier}Right`))).map((code) =>
|
||||
// code.endsWith("Left") && HANDED_KEY_ATTRIBUTE_VALUES.some((modifier) => code === `${modifier}Left`) ? code.replace("Left", "") : code
|
||||
// );
|
||||
const NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS = ["NumpadComma", "NumpadDecimal"];
|
||||
const SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE = ["AltRight", ...NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS];
|
||||
const LOCALE_SPECIFIC_KEY_CODES_INFO = KEY_CODES.filter((key) => key.category === "writing-system" || SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE.includes(key.code));
|
||||
const LOCALE_SPECIFIC_KEY_CODES = LOCALE_SPECIFIC_KEY_CODES_INFO.map((info) => info.code);
|
||||
const WRITING_SYSTEM_SPECIAL_CHARS = Object.values(KEY_CODES)
|
||||
.filter((info) => info.category === "writing-system")
|
||||
.flatMap((info) => info.keys?.us?.split(" "))
|
||||
.filter((character): character is string => (!character ? false : !/[a-zA-Z0-9]/.test(character)));
|
||||
|
||||
const KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS = ["Control", "Meta", "Shift"];
|
||||
const KEY_ATTRIBUTE_VALUES = new Set([
|
||||
// Modifier
|
||||
"Alt", // Glyph modifier key
|
||||
"AltGraph", // Glyph modifier key
|
||||
"CapsLock", // Glyph modifier key
|
||||
"Control",
|
||||
"Fn",
|
||||
"FnLock",
|
||||
"Meta",
|
||||
"NumLock",
|
||||
"ScrollLock",
|
||||
"Shift",
|
||||
"Symbol",
|
||||
"SymbolLock",
|
||||
|
||||
// Legacy modifier
|
||||
"Hyper",
|
||||
"Super",
|
||||
|
||||
// White space
|
||||
"Enter", // Control character
|
||||
"Tab", // Control character
|
||||
|
||||
// Navigation
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
|
||||
// Editing
|
||||
"Backspace", // Control character
|
||||
"Clear",
|
||||
"Copy",
|
||||
"CrSel",
|
||||
"Cut",
|
||||
"Delete", // Control character
|
||||
"EraseEof",
|
||||
"ExSel",
|
||||
"Insert",
|
||||
"Paste",
|
||||
"Redo",
|
||||
"Undo",
|
||||
|
||||
// UI
|
||||
"Accept",
|
||||
"Again",
|
||||
"Attn",
|
||||
"Cancel",
|
||||
"ContextMenu",
|
||||
"Escape", // Control character
|
||||
"Execute",
|
||||
"Find",
|
||||
"Help",
|
||||
"Pause",
|
||||
"Play",
|
||||
"Props",
|
||||
"Select",
|
||||
"ZoomIn",
|
||||
"ZoomOut",
|
||||
|
||||
// Device
|
||||
"BrightnessDown",
|
||||
"BrightnessUp",
|
||||
"Eject",
|
||||
"LogOff",
|
||||
"Power",
|
||||
"PowerOff",
|
||||
"PrintScreen",
|
||||
"Hibernate",
|
||||
"Standby",
|
||||
"WakeUp",
|
||||
|
||||
// IME composition keys
|
||||
"AllCandidates",
|
||||
"Alphanumeric",
|
||||
"CodeInput",
|
||||
"Compose",
|
||||
"Convert",
|
||||
"Dead",
|
||||
"FinalMode",
|
||||
"GroupFirst",
|
||||
"GroupLast",
|
||||
"GroupNext",
|
||||
"GroupPrevious",
|
||||
"ModeChange",
|
||||
"NextCandidate",
|
||||
"NonConvert",
|
||||
"PreviousCandidate",
|
||||
"Process",
|
||||
"SingleCandidate",
|
||||
|
||||
// Korean-specific
|
||||
"HangulMode",
|
||||
"HanjaMode",
|
||||
"JunjaMode",
|
||||
|
||||
// Japanese-specific
|
||||
"Eisu",
|
||||
"Hankaku",
|
||||
"Hiragana",
|
||||
"HiraganaKatakana",
|
||||
"KanaMode",
|
||||
"KanjiMode",
|
||||
"Katakana",
|
||||
"Romaji",
|
||||
"Zenkaku",
|
||||
"ZenkakuHankaku",
|
||||
|
||||
// Common function
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12",
|
||||
"F13",
|
||||
"F14",
|
||||
"F15",
|
||||
"F16",
|
||||
"F17",
|
||||
"F18",
|
||||
"F19",
|
||||
"F20",
|
||||
"F21",
|
||||
"F22",
|
||||
"F23",
|
||||
"F24",
|
||||
"Soft1",
|
||||
"Soft2",
|
||||
"Soft3",
|
||||
"Soft4",
|
||||
"Soft5",
|
||||
"Soft6",
|
||||
"Soft7",
|
||||
"Soft8",
|
||||
"Soft9",
|
||||
"Soft10",
|
||||
"Soft11",
|
||||
"Soft12",
|
||||
"Soft13",
|
||||
"Soft14",
|
||||
"Soft15",
|
||||
"Soft16",
|
||||
"Soft17",
|
||||
"Soft18",
|
||||
"Soft19",
|
||||
"Soft20",
|
||||
"Soft21",
|
||||
"Soft22",
|
||||
"Soft23",
|
||||
"Soft24",
|
||||
|
||||
// Multimedia
|
||||
"ChannelDown",
|
||||
"ChannelUp",
|
||||
"Close",
|
||||
"MailForward",
|
||||
"MailReply",
|
||||
"MailSend",
|
||||
"MediaClose",
|
||||
"MediaFastForward",
|
||||
"MediaPause",
|
||||
"MediaPlay",
|
||||
"MediaPlayPause",
|
||||
"MediaRecord",
|
||||
"MediaRewind",
|
||||
"MediaStop",
|
||||
"MediaTrackNext",
|
||||
"MediaTrackPrevious",
|
||||
"New",
|
||||
"Open",
|
||||
"Print",
|
||||
"Save",
|
||||
"SpellCheck",
|
||||
|
||||
// Multimedia numpad
|
||||
"Digit11",
|
||||
"Digit12",
|
||||
|
||||
// Audio
|
||||
"AudioBalanceLeft",
|
||||
"AudioBalanceRight",
|
||||
"AudioBassBoostDown",
|
||||
"AudioBassBoostToggle",
|
||||
"AudioBassBoostUp",
|
||||
"AudioFaderFront",
|
||||
"AudioFaderRear",
|
||||
"AudioSurroundModeNext",
|
||||
"AudioTrebleDown",
|
||||
"AudioTrebleUp",
|
||||
"AudioVolumeDown",
|
||||
"AudioVolumeUp",
|
||||
"AudioVolumeMute",
|
||||
"MicrophoneToggle",
|
||||
"MicrophoneVolumeDown",
|
||||
"MicrophoneVolumeUp",
|
||||
"MicrophoneVolumeMute",
|
||||
|
||||
// Speech
|
||||
"SpeechCorrectionList",
|
||||
"SpeechInputToggle",
|
||||
|
||||
// Application
|
||||
"LaunchApplication1",
|
||||
"LaunchApplication2",
|
||||
"LaunchCalendar",
|
||||
"LaunchContacts",
|
||||
"LaunchMail",
|
||||
"LaunchMediaPlayer",
|
||||
"LaunchMusicPlayer",
|
||||
"LaunchPhone",
|
||||
"LaunchScreenSaver",
|
||||
"LaunchSpreadsheet",
|
||||
"LaunchWebBrowser",
|
||||
"LaunchWebCam",
|
||||
"LaunchWordProcessor",
|
||||
|
||||
// Browser
|
||||
"BrowserBack",
|
||||
"BrowserFavorites",
|
||||
"BrowserForward",
|
||||
"BrowserHome",
|
||||
"BrowserRefresh",
|
||||
"BrowserSearch",
|
||||
"BrowserStop",
|
||||
|
||||
// Mobile phone
|
||||
"AppSwitch",
|
||||
"Call",
|
||||
"Camera",
|
||||
"CameraFocus",
|
||||
"EndCall",
|
||||
"GoBack",
|
||||
"GoHome",
|
||||
"HeadsetHook",
|
||||
"LastNumberRedial",
|
||||
"Notification",
|
||||
"MannerMode",
|
||||
"VoiceDial",
|
||||
|
||||
// TV
|
||||
"TV",
|
||||
"TV3DMode",
|
||||
"TVAntennaCable",
|
||||
"TVAudioDescription",
|
||||
"TVAudioDescriptionMixDown",
|
||||
"TVAudioDescriptionMixUp",
|
||||
"TVContentsMenu",
|
||||
"TVDataService",
|
||||
"TVInput",
|
||||
"TVInputComponent1",
|
||||
"TVInputComponent2",
|
||||
"TVInputComposite1",
|
||||
"TVInputComposite2",
|
||||
"TVInputHDMI1",
|
||||
"TVInputHDMI2",
|
||||
"TVInputHDMI3",
|
||||
"TVInputHDMI4",
|
||||
"TVInputVGA1",
|
||||
"TVMediaContext",
|
||||
"TVNetwork",
|
||||
"TVNumberEntry",
|
||||
"TVPower",
|
||||
"TVRadioService",
|
||||
"TVSatellite",
|
||||
"TVSatelliteBS",
|
||||
"TVSatelliteCS",
|
||||
"TVSatelliteToggle",
|
||||
"TVTerrestrialAnalog",
|
||||
"TVTerrestrialDigital",
|
||||
"TVTimer",
|
||||
|
||||
// Media controls
|
||||
"AVRInput",
|
||||
"AVRPower",
|
||||
"ColorF0Red",
|
||||
"ColorF1Green",
|
||||
"ColorF2Yellow",
|
||||
"ColorF3Blue",
|
||||
"ColorF4Grey",
|
||||
"ColorF5Brown",
|
||||
"ClosedCaptionToggle",
|
||||
"Dimmer",
|
||||
"DisplaySwap",
|
||||
"DVR",
|
||||
"Exit",
|
||||
"FavoriteClear0",
|
||||
"FavoriteClear1",
|
||||
"FavoriteClear2",
|
||||
"FavoriteClear3",
|
||||
"FavoriteRecall0",
|
||||
"FavoriteRecall1",
|
||||
"FavoriteRecall2",
|
||||
"FavoriteRecall3",
|
||||
"FavoriteStore0",
|
||||
"FavoriteStore1",
|
||||
"FavoriteStore2",
|
||||
"FavoriteStore3",
|
||||
"Guide",
|
||||
"GuideNextDay",
|
||||
"GuidePreviousDay",
|
||||
"Info",
|
||||
"InstantReplay",
|
||||
"Link",
|
||||
"ListProgram",
|
||||
"LiveContent",
|
||||
"Lock",
|
||||
"MediaApps",
|
||||
"MediaAudioTrack",
|
||||
"MediaLast",
|
||||
"MediaSkipBackward",
|
||||
"MediaSkipForward",
|
||||
"MediaStepBackward",
|
||||
"MediaStepForward",
|
||||
"MediaTopMenu",
|
||||
"NavigateIn",
|
||||
"NavigateNext",
|
||||
"NavigateOut",
|
||||
"NavigatePrevious",
|
||||
"NextFavoriteChannel",
|
||||
"NextUserProfile",
|
||||
"OnDemand",
|
||||
"Pairing",
|
||||
"PinPDown",
|
||||
"PinPMove",
|
||||
"PinPToggle",
|
||||
"PinPUp",
|
||||
"PlaySpeedDown",
|
||||
"PlaySpeedReset",
|
||||
"PlaySpeedUp",
|
||||
"RandomToggle",
|
||||
"RcLowBattery",
|
||||
"RecordSpeedNext",
|
||||
"RfBypass",
|
||||
"ScanChannelsToggle",
|
||||
"ScreenModeNext",
|
||||
"Settings",
|
||||
"SplitScreenToggle",
|
||||
"STBInput",
|
||||
"STBPower",
|
||||
"Subtitle",
|
||||
"Teletext",
|
||||
"VideoModeNext",
|
||||
"Wink",
|
||||
"ZoomToggle",
|
||||
|
||||
// Unidentified
|
||||
"Unidentified",
|
||||
]);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type RequestResult = { body: string; status: number };
|
||||
|
||||
// Special implementation using the legacy XMLHttpRequest API that provides callbacks to get:
|
||||
// - Calls with the percent progress uploading the request to the server
|
||||
// - Calls when downloading the result from the server, after the server has begun streaming back the response data
|
||||
// It returns a tuple of the promise as well as the XHR which can be used to call the `.abort()` method on it.
|
||||
export function requestWithUploadDownloadProgress(
|
||||
url: string,
|
||||
method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH",
|
||||
body: string,
|
||||
uploadProgress: (progress: number) => void,
|
||||
downloadOccurring: () => void,
|
||||
): [Promise<RequestResult>, XMLHttpRequest | undefined] {
|
||||
let xhrValue: XMLHttpRequest | undefined;
|
||||
const promise = new Promise<RequestResult>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (e) => uploadProgress(e.loaded / e.total));
|
||||
xhr.addEventListener("progress", () => downloadOccurring());
|
||||
xhr.addEventListener("load", () => resolve({ status: xhr.status, body: xhr.responseText }));
|
||||
xhr.addEventListener("abort", () => resolve({ status: xhr.status, body: xhr.responseText }));
|
||||
xhr.addEventListener("error", () => reject(new Error("Request error")));
|
||||
xhr.open(method, url, true);
|
||||
xhr.setRequestHeader("accept", "*/*");
|
||||
xhr.setRequestHeader("accept-language", "en-US,en;q=0.9");
|
||||
xhr.setRequestHeader("content-type", "application/json");
|
||||
|
||||
xhrValue = xhr;
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
|
||||
return [promise, xhrValue];
|
||||
}
|
||||
|
||||
// If the URL hash fragment contains a demo artwork path (e.g. #demo/isometric-light), fetch and open it
|
||||
export async function loadDemoArtwork(editor: EditorWrapper) {
|
||||
const demoArtwork = window.location.hash.trim().match(/#demo\/(.*)/)?.[1];
|
||||
if (!demoArtwork) return;
|
||||
|
||||
try {
|
||||
const url = new URL(`/demo-artwork/${demoArtwork}.${editor.fileExtension()}`, document.location.href);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error();
|
||||
|
||||
const filename = url.pathname.split("/").pop() || `Untitled.${editor.fileExtension()}`;
|
||||
const content = await response.bytes();
|
||||
editor.openFile(filename, content);
|
||||
|
||||
history.replaceState("", "", `${window.location.pathname}${window.location.search}`);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||