diff --git a/.gitignore b/.gitignore index fdc05bd3..e8f4408f 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk + +test-results/ diff --git a/src/lib/GlobalCssProperties.json b/src/lib/GlobalCssProperties.json new file mode 100644 index 00000000..5511ae82 --- /dev/null +++ b/src/lib/GlobalCssProperties.json @@ -0,0 +1,319 @@ +{ + "mediaSchemes": [ + { + "mediaFeature": "standard", + "color": [ + { + "attributeName": "--navigationbar-text-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 1, + "valueTwo": 1, + "valueThree": 1 + } + }, + { + "attributeName": "--console-scrollbar-thumb-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.22745098039, + "valueTwo": 0.27450980392, + "valueThree": 0.30588235294 + } + }, + { + "attributeName": "--console-scrollbar-thumbhover-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.2862745098, + "valueTwo": 0.34901960784, + "valueThree": 0.38823529411 + } + }, + { + "attributeName": "--query-success-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.31372549019, + "valueTwo": 0.54901960784, + "valueThree": 0.27450980392 + } + }, + { + "attributeName": "--query-warning-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.98431372549, + "valueTwo": 0.75294117647, + "valueThree": 0.17647058823 + } + }, + { + "attributeName": "--query-error-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.82745098039, + "valueTwo": 0.18431372549, + "valueThree": 0.18431372549 + } + } + ], + "border": [ + { + "attributeName": "--main-navigationbar-border", + "style": "solid", + "width": { + "size": 0.1, + "unit": "em" + }, + "color": { + "colorGamut": "display-p3", + "valueOne": 0.18823529411, + "valueTwo": 0.22745098039, + "valueThree": 0.25098039215 + } + }, + { + "attributeName": "--main-innernavigationbar-border", + "style": "none solid solid solid", + "width": { + "size": 0.1, + "unit": "em" + }, + "color": { + "colorGamut": "display-p3", + "valueOne": 0.18823529411, + "valueTwo": 0.22745098039, + "valueThree": 0.25098039215 + } + } + ] + }, + { + "mediaFeature": "prefers-color-scheme: light", + "color": [ + { + "attributeName": "--main-navigationbar-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.22745098039, + "valueTwo": 0.27450980392, + "valueThree": 0.30588235294 + } + }, + { + "attributeName": "--canvas-topbar-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.2862745098, + "valueTwo": 0.34901960784, + "valueThree": 0.38823529411 + } + }, + { + "attributeName": "--canvas-text-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0, + "valueTwo": 0, + "valueThree": 0 + } + }, + { + "attributeName": "--sidebar-text-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0, + "valueTwo": 0, + "valueThree": 0 + } + }, + { + "attributeName": "--background-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.95686274509, + "valueTwo": 0.95686274509, + "valueThree": 0.95686274509 + } + }, + { + "attributeName": "--console-selectedtab-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.2862745098, + "valueTwo": 0.34901960784, + "valueThree": 0.38823529411 + } + }, + { + "attributeName": "--console-unselectedtab-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.22745098039, + "valueTwo": 0.27450980392, + "valueThree": 0.30588235294 + } + }, + { + "attributeName": "--console-topbar-background-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.22745098039, + "valueTwo": 0.27450980392, + "valueThree": 0.30588235294 + } + }, + { + "attributeName": "--console-text-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0, + "valueTwo": 0, + "valueThree": 0 + } + }, + { + "attributeName": "--sidebar-element-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.93333333333, + "valueTwo": 0.93333333333, + "valueThree": 0.93333333333 + } + }, + { + "attributeName": "--sidebar-element-hover-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.81176470588, + "valueTwo": 0.84705882352, + "valueThree": 0.86274509803 + } + }, + { + "attributeName": "--queries-input-background-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 1, + "valueTwo": 1, + "valueThree": 1 + } + } + ] + }, + { + "mediaFeature": "prefers-color-scheme: dark", + "color": [ + { + "attributeName": "--main-navigationbar-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.0156862745, + "valueTwo": 0.02352941176, + "valueThree": 0.03529411764 + } + }, + { + "attributeName": "--canvas-topbar-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.05490196078, + "valueTwo": 0.06666666666, + "valueThree": 0.09019607843 + } + }, + { + "attributeName": "--canvas-text-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 1, + "valueTwo": 1, + "valueThree": 1 + } + }, + { + "attributeName": "--sidebar-text-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 1, + "valueTwo": 1, + "valueThree": 1 + } + }, + { + "attributeName": "--background-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.09019607843, + "valueTwo": 0.10196078431, + "valueThree": 0.13333333333 + } + }, + { + "attributeName": "--console-selectedtab-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.05490196078, + "valueTwo": 0.06666666666, + "valueThree": 0.09019607843 + } + }, + { + "attributeName": "--console-unselectedtab-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.0156862745, + "valueTwo": 0.02352941176, + "valueThree": 0.03529411764 + } + }, + { + "attributeName": "--console-topbar-background-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.0156862745, + "valueTwo": 0.02352941176, + "valueThree": 0.03529411764 + } + }, + { + "attributeName": "--console-text-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 1, + "valueTwo": 1, + "valueThree": 1 + } + }, + { + "attributeName": "--sidebar-element-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.19215686274, + "valueTwo": 0.21176470588, + "valueThree": 0.23529411764 + } + }, + { + "attributeName": "--sidebar-element-hover-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.12215686274, + "valueTwo": 0.23176470588, + "valueThree": 0.25529411764 + } + }, + { + "attributeName": "--queries-input-background-color", + "color": { + "colorGamut": "display-p3", + "valueOne": 0.05490196078, + "valueTwo": 0.06666666666, + "valueThree": 0.09019607843 + } + } + ] + } + ] +} diff --git a/src/lib/classes/styling/GlobalCssSchemesLoader.ts b/src/lib/classes/styling/GlobalCssSchemesLoader.ts new file mode 100644 index 00000000..6613ee15 --- /dev/null +++ b/src/lib/classes/styling/GlobalCssSchemesLoader.ts @@ -0,0 +1,229 @@ +import type ColorValue from "./ZodSchemas/GenericSchemas/ColorValue"; +import type MediaScheme from "./ZodSchemas/MediaScheme"; + +import MediaSchemes from "./ZodSchemas/MediaSchemes"; +import GlobalCssProperties from "../../GlobalCssProperties.json"; + +import type { z } from "zod"; + +/** + * Class for handling the loading of different properties based on active media features + */ +class GlobalCssSchemesLoader { + private _window: Window; + private _mediaSchemes: z.infer[]; + private _propertyNames: string[] = []; + + // SUPPORTED MEDIA FEATURES + private _supportedMediaFeatures: string[] = [ + "prefers-color-scheme: dark", + "prefers-color-scheme: light", + "prefers-reduced-motion", + "prefers-reduced-transparency", + ]; + + constructor(window: Window) { + this._window = window; + + // Parse and apply the different properties + this._mediaSchemes = this.parseMediaFeatures(); + this.applyMediaFeatures(); + + // Add event listeners to supported features + this.addEventListeners(); + + // Gather the property names + this.gatherPropertyNames(); + } + + /** + * Method for applying the specified styles + */ + applyMediaFeatures() { + // Apply each of the mediafeatures in the order in which they are specified in the .json file + this._mediaSchemes.forEach((scheme) => { + // Return early if the medie feature does not match + if ( + !this._window.matchMedia(`(${scheme.mediaFeature})`).matches && + scheme.mediaFeature !== "standard" + ) { + return; + } + + // Set color properties + if (scheme.color) { + scheme.color.forEach((attribute) => { + this._window.document.documentElement.style.setProperty( + attribute.attributeName, + this.createCssColor(attribute.color), + ); + }); + } + + // Set fontSize properties + if (scheme.fontSize) { + scheme.fontSize.forEach((attribute) => { + this._window.document.documentElement.style.setProperty( + attribute.attributeName, + attribute.size.size + attribute.size.unit, //TODO: Check the font size number + ); + }); + } + + // Set border properties + if (scheme.border) { + scheme.border.forEach((attribute) => { + this._window.document.documentElement.style.setProperty( + attribute.attributeName, + this.createCssColor(attribute.color) + + " " + + attribute.style + + " " + + attribute.width.size + + attribute.width.unit, + ); + }); + } + }); + } + + /** + * Method for clearing the applied styles + */ + private clearAppliedProperties() { + this._propertyNames.forEach((attribute) => { + this._window.document.documentElement.style.removeProperty( + attribute, + ); + }); + } + + /** + * Method for re-applying the specified styles + */ + reapplyMediaFeatures() { + this.clearAppliedProperties(); + this.applyMediaFeatures(); + } + + /** + * Method that gathers all property names from the config to be able to remove them later + */ + gatherPropertyNames() { + this._mediaSchemes.forEach((scheme) => { + // Check and add color attribute names + if (scheme.color) { + scheme.color.forEach((attr) => { + if (!this._propertyNames.includes(attr.attributeName)) { + this._propertyNames.push(attr.attributeName); + } + }); + } + + // Check and add fontSize attribute names + if (scheme.fontSize) { + scheme.fontSize.forEach((attr) => { + if (!this._propertyNames.includes(attr.attributeName)) { + this._propertyNames.push(attr.attributeName); + } + }); + } + + // Check and add border attribute names + if (scheme.border) { + scheme.border.forEach((attr) => { + if (!this._propertyNames.includes(attr.attributeName)) { + this._propertyNames.push(attr.attributeName); + } + }); + } + }); + } + + /** + * Method for loading the ColorSchemes.json file + */ + private parseMediaFeatures(): z.infer[] { + // Parsing media features + const parsedMediaFeatures = MediaSchemes.safeParse(GlobalCssProperties); + + // Throwing error if the parsing failed + if (!parsedMediaFeatures.success) { + throw new Error(parsedMediaFeatures.error.message); + } + + return parsedMediaFeatures.data.mediaSchemes; + } + + /** + * Method for adding appropriate event listeners + */ + private addEventListeners() { + this._supportedMediaFeatures.forEach((feature) => { + this._window + .matchMedia(`(${feature})`) + .addEventListener("change", () => { + this.reapplyMediaFeatures(); + }); + }); + } + + /** + * Method for checking and creating CSS color string + * @param color + * @returns CSS color string + */ + private createCssColor(color: z.infer): string { + const supportedGamuts: string[] = [ + "srgb", + "srgb-linear", + "display-p3", + "a98-rgb", + "prophoto-rgb", + "rec2020", + "xyz", + "xyz-d50", + "xyz-d65", + ]; + let cssColor: string; + + // Check if color gamut is supported + if (!supportedGamuts.includes(color.colorGamut)) { + throw new Error( + `Color gamut "${color.colorGamut}" specified in parsed global css styles, is not supported."`, + ); + } + + // Check if values are within range (0.0 - 1.0) + if ( + this.outOfColorRange(color.valueOne) || + this.outOfColorRange(color.valueTwo) || + this.outOfColorRange(color.valueThree) || + (color.alpha && this.outOfColorRange(color.alpha)) + ) { + throw new Error( + "Color value in parsed global css styles out of range (0.0 - 1.0).", + ); + } + + // Create CSS color string + if (color.alpha) { + cssColor = `color(${color.colorGamut} ${color.valueOne} ${color.valueTwo} ${color.valueThree} / ${color.alpha})`; + } else { + cssColor = `color(${color.colorGamut} ${color.valueOne} ${color.valueTwo} ${color.valueThree})`; + } + + return cssColor; + } + + /** + * Support method for checking if color is within range + * @param value + * @returns Boolean value representing if value is out of range + */ + private outOfColorRange(value: number): boolean { + return value > 1 || value < 0; + } +} + +export default GlobalCssSchemesLoader; diff --git a/src/lib/classes/styling/ZodSchemas/AttributeSchemas/BorderAttribute.ts b/src/lib/classes/styling/ZodSchemas/AttributeSchemas/BorderAttribute.ts new file mode 100644 index 00000000..09124759 --- /dev/null +++ b/src/lib/classes/styling/ZodSchemas/AttributeSchemas/BorderAttribute.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import ColorValue from "../GenericSchemas/ColorValue"; +import Size from "../GenericSchemas/SizeValue"; + +/** + * Represents a border in CSS. + * A border has a string containing the styles of the border, a width representing the thickness of the border and a color. + */ + +const BorderAttribute = z.object({ + attributeName: z.string(), + style: z.string(), + width: Size, + color: ColorValue, +}); + +export default BorderAttribute; diff --git a/src/lib/classes/styling/ZodSchemas/AttributeSchemas/ColorAttribute.ts b/src/lib/classes/styling/ZodSchemas/AttributeSchemas/ColorAttribute.ts new file mode 100644 index 00000000..52702a85 --- /dev/null +++ b/src/lib/classes/styling/ZodSchemas/AttributeSchemas/ColorAttribute.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import ColorValue from "../GenericSchemas/ColorValue"; + +const ColorAttribute = z.object({ + attributeName: z.string(), + color: ColorValue, +}); + +export default ColorAttribute; diff --git a/src/lib/classes/styling/ZodSchemas/AttributeSchemas/FontSizeAttribute.ts b/src/lib/classes/styling/ZodSchemas/AttributeSchemas/FontSizeAttribute.ts new file mode 100644 index 00000000..46bcbbec --- /dev/null +++ b/src/lib/classes/styling/ZodSchemas/AttributeSchemas/FontSizeAttribute.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import SizeValue from "../GenericSchemas/SizeValue"; + +const FontSizeAttribute = z.object({ + attributeName: z.string(), + size: SizeValue, +}); + +export default FontSizeAttribute; diff --git a/src/lib/classes/styling/ZodSchemas/GenericSchemas/ColorValue.ts b/src/lib/classes/styling/ZodSchemas/GenericSchemas/ColorValue.ts new file mode 100644 index 00000000..20077279 --- /dev/null +++ b/src/lib/classes/styling/ZodSchemas/GenericSchemas/ColorValue.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +/** + * The values for the colors must be within the range of 0-1 as the implementation is based on the color() css function. + * See information about the color function on: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color. + * The alpha value is optional, and is also a number within the range 0-1. + * + * Example: + * To represent the color "rgb(255 0 0)"" it is writen as "color(srgb 1 0 0)". + * To convert the individual RGB values from the range 0-255 to the range 0-1 one simply has to use the following formula: + * (old value)/255 = new value + */ + +const ColorValue = z.object({ + colorGamut: z.string(), + valueOne: z.number(), + valueTwo: z.number(), + valueThree: z.number(), + alpha: z.number().optional(), +}); + +export default ColorValue; diff --git a/src/lib/classes/styling/ZodSchemas/GenericSchemas/SizeValue.ts b/src/lib/classes/styling/ZodSchemas/GenericSchemas/SizeValue.ts new file mode 100644 index 00000000..57e5b693 --- /dev/null +++ b/src/lib/classes/styling/ZodSchemas/GenericSchemas/SizeValue.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +/** + * A size is represented using a number and a string. + * Example: "3em" would be represented as "size: 3" and "unit: 'em'" + */ + +const SizeValue = z.object({ + size: z.number(), + unit: z.string(), +}); + +export default SizeValue; diff --git a/src/lib/classes/styling/ZodSchemas/MediaScheme.ts b/src/lib/classes/styling/ZodSchemas/MediaScheme.ts new file mode 100644 index 00000000..8d28d991 --- /dev/null +++ b/src/lib/classes/styling/ZodSchemas/MediaScheme.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import ColorAttribute from "./AttributeSchemas/ColorAttribute"; +import BorderAttribute from "./AttributeSchemas/BorderAttribute"; +import FontSizeAttribute from "./AttributeSchemas/FontSizeAttribute"; + +/** + * A MediaScheme represents one of the many media queries. + * The different attributes of the specific mediascheme are added if that specific media feature is currently active. + * + * A complete list of the different supported media queries can be seen on the following website: + * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries + */ + +const MediaScheme = z.object({ + mediaFeature: z.string(), + color: z.array(ColorAttribute).optional(), + fontSize: z.array(FontSizeAttribute).optional(), + border: z.array(BorderAttribute).optional(), +}); + +export default MediaScheme; diff --git a/src/lib/classes/styling/ZodSchemas/MediaSchemes.ts b/src/lib/classes/styling/ZodSchemas/MediaSchemes.ts new file mode 100644 index 00000000..19be698a --- /dev/null +++ b/src/lib/classes/styling/ZodSchemas/MediaSchemes.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; +import MediaScheme from "./MediaScheme"; + +const MediaSchemes = z.object({ + mediaSchemes: z.array(MediaScheme), +}); + +export default MediaSchemes; diff --git a/src/lib/components/console/Console.svelte b/src/lib/components/console/Console.svelte index 888eda24..c83dc85a 100644 --- a/src/lib/components/console/Console.svelte +++ b/src/lib/components/console/Console.svelte @@ -14,13 +14,13 @@ let frontEndErrors: string[] = []; let backEndErrors: string[] = []; - const consoleInitialSize: string = "20em"; - let consoleExtendedSize: string = consoleInitialSize; - let consoleCollapsedSize: string = "3.25em"; + const consoleInitialSize: number = 300; + let consoleExtendedSize: number = consoleInitialSize; + let consoleCollapsedSize: number = 0; let consoleSize = consoleCollapsedSize; - let consoleButtonColorOff: string = "slategrey"; - let consoleButtonColorOn: string = "rgb(159, 174, 189)"; + let consoleButtonColorOff: string = "var(--console-unselectedtab-color)"; + let consoleButtonColorOn: string = "var(--console-selectedtab-color)"; /** * Function for resizing the console @@ -28,7 +28,7 @@ */ function resizeConsolePanel(event: PointerEvent) { event.preventDefault(); - consoleSize = (window.innerHeight - event.y).toString() + "px"; + consoleSize = window.innerHeight - event.y - consoleBar.offsetHeight; if (window.innerHeight - event.y < consoleBar.clientHeight) { consoleSize = consoleInitialSize; stopResizingConsolePanel(event); @@ -120,12 +120,8 @@ } -
-
+
+
{#if currentlyCollapsed} - + {:else} - + {/if} @@ -173,7 +169,7 @@ Backend
-
+
{#if currentTab == Tabs.Frontend} {#each frontEndErrors as error} @@ -188,16 +184,19 @@ diff --git a/src/lib/components/query/QueryNav.svelte b/src/lib/components/query/QueryNav.svelte index 1b43fdf6..7921b0e3 100644 --- a/src/lib/components/query/QueryNav.svelte +++ b/src/lib/components/query/QueryNav.svelte @@ -7,9 +7,9 @@

Queries

- - - + + +
diff --git a/src/lib/components/startScreen/StartScreen.svelte b/src/lib/components/startScreen/StartScreen.svelte index d808937f..1ceae4c6 100644 --- a/src/lib/components/startScreen/StartScreen.svelte +++ b/src/lib/components/startScreen/StartScreen.svelte @@ -9,5 +9,7 @@

Welcome to Ecdar

- +
diff --git a/src/lib/components/svg-view/DraggableSVG.svelte b/src/lib/components/svg-view/DraggableSVG.svelte new file mode 100644 index 00000000..0f1af836 --- /dev/null +++ b/src/lib/components/svg-view/DraggableSVG.svelte @@ -0,0 +1,38 @@ + + + + + + diff --git a/src/lib/components/svg-view/Edge.svelte b/src/lib/components/svg-view/Edge.svelte new file mode 100644 index 00000000..79a415b9 --- /dev/null +++ b/src/lib/components/svg-view/Edge.svelte @@ -0,0 +1,98 @@ + + + +{#each lines as line, index} + +{/each} + + +{#each nails as nail} + +{/each} + + diff --git a/src/lib/components/svg-view/Label.svelte b/src/lib/components/svg-view/Label.svelte new file mode 100644 index 00000000..de44dea7 --- /dev/null +++ b/src/lib/components/svg-view/Label.svelte @@ -0,0 +1,50 @@ + + + + + + + + + + + + {text} + + + + diff --git a/src/lib/components/svg-view/Location.svelte b/src/lib/components/svg-view/Location.svelte new file mode 100644 index 00000000..6f7505b1 --- /dev/null +++ b/src/lib/components/svg-view/Location.svelte @@ -0,0 +1,34 @@ + + + + + + + diff --git a/src/lib/components/svg-view/Nail.svelte b/src/lib/components/svg-view/Nail.svelte new file mode 100644 index 00000000..accf7421 --- /dev/null +++ b/src/lib/components/svg-view/Nail.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/src/lib/components/svg-view/Node.svelte b/src/lib/components/svg-view/Node.svelte new file mode 100644 index 00000000..704d7674 --- /dev/null +++ b/src/lib/components/svg-view/Node.svelte @@ -0,0 +1,27 @@ + + + + + {text} + diff --git a/src/lib/components/svg-view/SvgView.svelte b/src/lib/components/svg-view/SvgView.svelte new file mode 100644 index 00000000..20ebd820 --- /dev/null +++ b/src/lib/components/svg-view/SvgView.svelte @@ -0,0 +1,126 @@ + + + panzoom?.handleDown(event)} + on:wheel={(event) => panzoom?.zoomWithWheel(event)} + class="panzoom-parent" +> + + + {#each $activeModel.edges as edge} + + {/each} + + + {#each Object.values({ ...$activeModel.locations }) as location} + + {/each} + + + + + + + + + + + diff --git a/src/lib/components/svg-view/panzoom/css.ts b/src/lib/components/svg-view/panzoom/css.ts new file mode 100644 index 00000000..f7e42f61 --- /dev/null +++ b/src/lib/components/svg-view/panzoom/css.ts @@ -0,0 +1,80 @@ +import type { CurrentValues } from "./types"; + +/** + * Gets a style value expected to be a number + */ +export function getCSSNum(name: string, style: CSSStyleDeclaration) { + return parseFloat(style.getPropertyValue(name)) || 0; +} + +function getBoxStyle( + elem: HTMLElement | SVGElement, + name: string, + style: CSSStyleDeclaration = window.getComputedStyle(elem), +) { + // Support: FF 68+ + // Firefox requires specificity for border + const suffix = name === "border" ? "Width" : ""; + return { + left: getCSSNum(`${name}Left${suffix}`, style), + right: getCSSNum(`${name}Right${suffix}`, style), + top: getCSSNum(`${name}Top${suffix}`, style), + bottom: getCSSNum(`${name}Bottom${suffix}`, style), + }; +} + +/** + * Activate or deactivate animated transitions on the DOM element + */ +export type setTransition = (active: boolean) => void; + +/** + * Set the transform of the DOM element + * + * ```js + * // This example always sets a rotation + * // when setting the scale and translation + * const panzoom = Panzoom(elem, { + * setTransform: (elem, { scale, x, y }) => { + * panzoom.setStyle('transform', `rotate(0.5turn) scale(${scale}) translate(${x}px, ${y}px)`) + * } + * }) + * ``` + */ +export type setTransform = (values: CurrentValues) => void; + +/** + * Dimensions used in containment and focal point zooming + */ +export function getDimensions(elem: HTMLElement | SVGElement) { + const parent = elem.parentNode as HTMLElement | SVGElement; + const style = window.getComputedStyle(elem); + const parentStyle = window.getComputedStyle(parent); + const rectElem = elem.getBoundingClientRect(); + const rectParent = parent.getBoundingClientRect(); + + return { + elem: { + style, + width: rectElem.width, + height: rectElem.height, + top: rectElem.top, + bottom: rectElem.bottom, + left: rectElem.left, + right: rectElem.right, + margin: getBoxStyle(elem, "margin", style), + border: getBoxStyle(elem, "border", style), + }, + parent: { + style: parentStyle, + width: rectParent.width, + height: rectParent.height, + top: rectParent.top, + bottom: rectParent.bottom, + left: rectParent.left, + right: rectParent.right, + padding: getBoxStyle(parent, "padding", parentStyle), + border: getBoxStyle(parent, "border", parentStyle), + }, + }; +} diff --git a/src/lib/components/svg-view/panzoom/isExcluded.ts b/src/lib/components/svg-view/panzoom/isExcluded.ts new file mode 100644 index 00000000..776ed55a --- /dev/null +++ b/src/lib/components/svg-view/panzoom/isExcluded.ts @@ -0,0 +1,30 @@ +import type { PanzoomOptions } from "./types"; + +function getClass(elem: Element) { + return (elem.getAttribute("class") || "").trim(); +} + +function hasClass(elem: Element, className: string) { + return ( + elem.nodeType === 1 && ` ${getClass(elem)} `.includes(` ${className} `) + ); +} + +export default function isExcluded(elem: Element, options: PanzoomOptions) { + let cur: ParentNode | null = elem; + while (cur !== null) { + if ( + isElement(cur) && + (hasClass(cur, options.excludeClass) || + options.exclude.includes(cur)) + ) { + return true; + } + cur = cur.parentNode; + } + return false; +} + +function isElement(value: unknown): value is Element { + return value instanceof Element; +} diff --git a/src/lib/components/svg-view/panzoom/panzoom.ts b/src/lib/components/svg-view/panzoom/panzoom.ts new file mode 100644 index 00000000..6a33c41d --- /dev/null +++ b/src/lib/components/svg-view/panzoom/panzoom.ts @@ -0,0 +1,603 @@ +/** + * @file + * Code adapted from https://www.npmjs.com/package/@panzoom/panzoom/v/4.5.1 + * The original code is not very optimized for svelte. CSS values are set at runtime instead of compile time, and events are handled outside of the svelte event loop. + * We have forked the code to integrate it correctly with svelte. + * + * PLEASE DO NOT MODIFY unless it is very necessary. This code is intentionally structured to follow the source code, to make future upgrades/debugging easier. + */ + +import type { + PanOptions, + PanzoomEvent, + PanzoomEventDetail, + PanzoomObject, + PanzoomOptions, + ZoomOptions, +} from "./types"; +import { addPointer, getDistance, getMiddle, removePointer } from "./pointers"; +import { getDimensions, type setTransform, type setTransition } from "./css"; + +import isExcluded from "./isExcluded"; + +const defaultOptions: PanzoomOptions = { + animate: false, + canvas: false, + disablePan: false, + disableZoom: false, + disableXAxis: false, + disableYAxis: false, + exclude: [], + excludeClass: "panzoom-exclude", + force: false, + handleStartEvent: (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }, + maxScale: 4, + minScale: 0.125, + panOnlyWhenZoomed: false, + pinchAndPan: false, + relative: false, + roundPixels: false, + silent: false, + startX: 0, + startY: 0, + startScale: 1, + step: 0.3, +}; + +type OptionalPanzoomOptions = Partial>; + +class Panzoom implements PanzoomObject { + constructor( + private elem: HTMLElement | SVGElement, + private parent: HTMLElement | SVGElement, + private setTransform: setTransform, + private setTransition: setTransition, + options?: OptionalPanzoomOptions, + ) { + this.options = { + ...defaultOptions, + ...options, + }; + + this.zoom(this.options.startScale, { animate: false, force: true }); + // Wait for scale to update + // for accurate dimensions + // to constrain initial values + setTimeout(() => { + this.pan(this.options.startX, this.options.startY, { + animate: false, + force: true, + }); + }); + } + + private options: PanzoomOptions = defaultOptions; + + setOptions(opts: OptionalPanzoomOptions = {}) { + this.options = { ...this.options, ...opts }; + } + + private x = 0; + private y = 0; + private scale = 0; + + trigger( + eventName: PanzoomEvent, + detail: PanzoomEventDetail, + opts: OptionalPanzoomOptions, + ) { + if (opts.silent) { + return; + } + const event = new CustomEvent(eventName, { detail }); + this.elem.dispatchEvent(event); + } + + setTransformWithEvent( + eventName: PanzoomEvent, + opts: OptionalPanzoomOptions, + originalEvent?: PanzoomEventDetail["originalEvent"], + ) { + const value = { + x: this.x, + y: this.y, + scale: this.scale, + originalEvent, + }; + this.setTransition(Boolean(opts.animate)); + this.setTransform(value); + this.trigger(eventName, value, opts); + this.trigger("panzoomchange", value, opts); + return value; + } + + constrainXY( + toX: number | string, + toY: number | string, + toScale: number, + panOptions?: Partial, + ) { + const opts = { ...this.options, ...panOptions }; + const result = { x: this.x, y: this.y, opts }; + if ( + !opts.force && + (opts.disablePan || + (opts.panOnlyWhenZoomed && this.scale === opts.startScale)) + ) { + return result; + } + if (isString(toX)) toX = parseFloat(toX); + if (isString(toY)) toY = parseFloat(toY); + + if (!opts.disableXAxis) { + result.x = (opts.relative ? this.x : 0) + toX; + } + + if (!opts.disableYAxis) { + result.y = (opts.relative ? this.y : 0) + toY; + } + + if (opts.contain) { + const dims = getDimensions(this.elem); + const realWidth = dims.elem.width / this.scale; + const realHeight = dims.elem.height / this.scale; + const scaledWidth = realWidth * toScale; + const scaledHeight = realHeight * toScale; + const diffHorizontal = (scaledWidth - realWidth) / 2; + const diffVertical = (scaledHeight - realHeight) / 2; + + switch (opts.contain) { + case "inside": { + const minX = + (-dims.elem.margin.left - + dims.parent.padding.left + + diffHorizontal) / + toScale; + const maxX = + (dims.parent.width - + scaledWidth - + dims.parent.padding.left - + dims.elem.margin.left - + dims.parent.border.left - + dims.parent.border.right + + diffHorizontal) / + toScale; + result.x = Math.max(Math.min(result.x, maxX), minX); + const minY = + (-dims.elem.margin.top - + dims.parent.padding.top + + diffVertical) / + toScale; + const maxY = + (dims.parent.height - + scaledHeight - + dims.parent.padding.top - + dims.elem.margin.top - + dims.parent.border.top - + dims.parent.border.bottom + + diffVertical) / + toScale; + result.y = Math.max(Math.min(result.y, maxY), minY); + break; + } + case "outside": { + const minX = + (-(scaledWidth - dims.parent.width) - + dims.parent.padding.left - + dims.parent.border.left - + dims.parent.border.right + + diffHorizontal) / + toScale; + const maxX = + (diffHorizontal - dims.parent.padding.left) / toScale; + result.x = Math.max(Math.min(result.x, maxX), minX); + const minY = + (-(scaledHeight - dims.parent.height) - + dims.parent.padding.top - + dims.parent.border.top - + dims.parent.border.bottom + + diffVertical) / + toScale; + const maxY = + (diffVertical - dims.parent.padding.top) / toScale; + result.y = Math.max(Math.min(result.y, maxY), minY); + break; + } + } + } + + if (opts.roundPixels) { + result.x = Math.round(result.x); + result.y = Math.round(result.y); + } + + return result; + } + + constrainScale(toScale: number, zoomOptions?: Partial) { + const opts = { ...this.options, ...zoomOptions }; + const result = { scale: this.scale, opts }; + if (!opts.force && opts.disableZoom) { + return result; + } + + let minScale = this.options.minScale; + let maxScale = this.options.maxScale; + + if (opts.contain) { + const dims = getDimensions(this.elem); + const elemWidth = dims.elem.width / this.scale; + const elemHeight = dims.elem.height / this.scale; + if (elemWidth > 1 && elemHeight > 1) { + const parentWidth = + dims.parent.width - + dims.parent.border.left - + dims.parent.border.right; + const parentHeight = + dims.parent.height - + dims.parent.border.top - + dims.parent.border.bottom; + const elemScaledWidth = parentWidth / elemWidth; + const elemScaledHeight = parentHeight / elemHeight; + if (this.options.contain === "inside") { + maxScale = Math.min( + maxScale, + elemScaledWidth, + elemScaledHeight, + ); + } else if (this.options.contain === "outside") { + minScale = Math.max( + minScale, + elemScaledWidth, + elemScaledHeight, + ); + } + } + } + + result.scale = Math.min(Math.max(toScale, minScale), maxScale); + return result; + } + + pan( + toX: number | string, + toY: number | string, + panOptions?: Partial, + originalEvent?: PanzoomEventDetail["originalEvent"], + ) { + const result = this.constrainXY(toX, toY, this.scale, panOptions); + + // Only try to set if the result is somehow different + if (this.x !== result.x || this.y !== result.y) { + this.x = result.x; + this.y = result.y; + return this.setTransformWithEvent( + "panzoompan", + result.opts, + originalEvent, + ); + } + return { x: this.x, y: this.y, scale: this.scale, originalEvent }; + } + + zoom( + toScale: number, + zoomOptions?: Partial, + originalEvent?: PanzoomEventDetail["originalEvent"], + ) { + const result = this.constrainScale(toScale, zoomOptions); + const opts = result.opts; + if (!opts.force && opts.disableZoom) { + return; + } + toScale = result.scale; + let toX = this.x; + let toY = this.y; + + if (opts.focal) { + // The difference between the point after the scale and the point before the scale + // plus the current translation after the scale + // neutralized to no scale (as the transform scale will apply to the translation) + const focal = opts.focal; + toX = + (focal.x / toScale - focal.x / this.scale + this.x * toScale) / + toScale; + toY = + (focal.y / toScale - focal.y / this.scale + this.y * toScale) / + toScale; + } + const panResult = this.constrainXY(toX, toY, toScale, { + relative: false, + force: true, + }); + this.x = panResult.x; + this.y = panResult.y; + this.scale = toScale; + return this.setTransformWithEvent("panzoomzoom", opts, originalEvent); + } + + zoomInOut(isIn: boolean, zoomOptions?: Partial) { + const opts = { ...this.options, animate: true, ...zoomOptions }; + return this.zoom( + this.scale * Math.exp((isIn ? 1 : -1) * opts.step), + opts, + ); + } + + zoomIn(zoomOptions?: Partial) { + return this.zoomInOut(true, zoomOptions); + } + + zoomOut(zoomOptions?: Partial) { + return this.zoomInOut(false, zoomOptions); + } + + zoomToPoint( + toScale: number, + point: { clientX: number; clientY: number }, + zoomOptions?: Partial, + originalEvent?: PanzoomEventDetail["originalEvent"], + ) { + const dims = getDimensions(this.elem); + + // Instead of thinking of operating on the panzoom element, + // think of operating on the area inside the panzoom + // element's parent + // Subtract padding and border + const effectiveArea = { + width: + dims.parent.width - + dims.parent.padding.left - + dims.parent.padding.right - + dims.parent.border.left - + dims.parent.border.right, + height: + dims.parent.height - + dims.parent.padding.top - + dims.parent.padding.bottom - + dims.parent.border.top - + dims.parent.border.bottom, + }; + + // Adjust the clientX/clientY to ignore the area + // outside the effective area + const clientX = + point.clientX - + dims.parent.left - + dims.parent.padding.left - + dims.parent.border.left - + dims.elem.margin.left; + const clientY = + point.clientY - + dims.parent.top - + dims.parent.padding.top - + dims.parent.border.top - + dims.elem.margin.top; + + // Convert the mouse point from it's position over the + // effective area before the scale to the position + // over the effective area after the scale. + const focal = { + x: + (clientX / effectiveArea.width) * + (effectiveArea.width * toScale), + y: + (clientY / effectiveArea.height) * + (effectiveArea.height * toScale), + }; + + return this.zoom( + toScale, + { ...zoomOptions, animate: false, focal }, + originalEvent, + ); + } + + zoomWithWheel(event: WheelEvent, zoomOptions?: Partial) { + // Need to prevent the default here + // or it conflicts with regular page scroll + event.preventDefault(); + + const opts = { ...this.options, ...zoomOptions, animate: false }; + + // Normalize to deltaX in case shift modifier is used on Mac + const delta = + event.deltaY === 0 && event.deltaX ? event.deltaX : event.deltaY; + const wheel = delta < 0 ? 1 : -1; + const toScale = this.constrainScale( + this.scale * Math.exp((wheel * opts.step) / 3), + opts, + ).scale; + + return this.zoomToPoint(toScale, event, opts, event); + } + + reset(resetOptions?: OptionalPanzoomOptions) { + const opts = { + ...this.options, + animate: true, + force: true, + ...resetOptions, + }; + this.scale = this.constrainScale(opts.startScale, opts).scale; + const panResult = this.constrainXY( + opts.startX, + opts.startY, + this.scale, + opts, + ); + this.x = panResult.x; + this.y = panResult.y; + return this.setTransformWithEvent("panzoomreset", opts); + } + + private origX: number | undefined; + private origY: number | undefined; + private startClientX: number | undefined; + private startClientY: number | undefined; + private startScale: number = 1; + private startDistance: number = 0; + private pointers: PointerEvent[] = []; + + private listenerController: AbortController | undefined; + + handleDown(event: PointerEvent) { + // Don't handle this event if the target is excluded + if (isExcluded(event.target as Element, this.options)) { + return; + } + this.parent.setPointerCapture(event.pointerId); + addPointer(this.pointers, event); + this.options.handleStartEvent(event); + this.origX = this.x; + this.origY = this.y; + + this.trigger( + "panzoomstart", + { x: this.x, y: this.y, scale: this.scale, originalEvent: event }, + this.options, + ); + + // This works whether there are multiple + // pointers or not + const point = getMiddle(this.pointers); + this.startClientX = point.clientX; + this.startClientY = point.clientY; + this.startScale = this.scale; + this.startDistance = getDistance(this.pointers); + + // Register event listeners for panning + this.listenerController?.abort(); + this.listenerController = new AbortController(); + this.parent.addEventListener( + "pointermove", + (event) => { + this.handleMove(event as PointerEvent); + }, + { + passive: true, + signal: this.listenerController.signal, + }, + ); + this.parent.addEventListener( + "pointerup", + (event) => { + this.handleUp(event as PointerEvent); + }, + { + passive: true, + once: true, + signal: this.listenerController.signal, + }, + ); + this.parent.addEventListener( + "pointercancel", + (event) => { + this.handleUp(event as PointerEvent); + }, + { + passive: true, + once: true, + signal: this.listenerController.signal, + }, + ); + } + + handleMove(event: PointerEvent) { + if ( + this.origX === undefined || + this.origY === undefined || + this.startClientX === undefined || + this.startClientY === undefined + ) { + return; + } + addPointer(this.pointers, event); + const current = getMiddle(this.pointers); + const hasMultiple = this.pointers.length > 1; + let toScale = this.scale; + + if (hasMultiple) { + // A startDistance of 0 means + // that there weren't 2 pointers + // handled on start + if (this.startDistance === 0) { + this.startDistance = getDistance(this.pointers); + } + // Use the distance between the first 2 pointers + // to determine the current scale + const diff = getDistance(this.pointers) - this.startDistance; + toScale = this.constrainScale( + (diff * this.options.step) / 80 + this.startScale, + ).scale; + this.zoomToPoint(toScale, current, { animate: false }, event); + } + + // Pan during pinch if pinchAndPan is true. + // Note: some calculations may be off because the zoom + // above has not yet rendered. However, the behavior + // was removed before the new scale was used in the following + // pan calculation. + // See https://github.com/timmywil/panzoom/issues/512 + // and https://github.com/timmywil/panzoom/issues/606 + if (!hasMultiple || this.options.pinchAndPan) { + this.pan( + this.origX + (current.clientX - this.startClientX) / toScale, + this.origY + (current.clientY - this.startClientY) / toScale, + { + animate: false, + }, + event, + ); + } + } + + handleUp(event: PointerEvent) { + // Don't call panzoomend when panning with 2 touches + // until both touches end + if (this.pointers.length === 1) { + this.trigger( + "panzoomend", + { + x: this.x, + y: this.y, + scale: this.scale, + originalEvent: event, + }, + this.options, + ); + } + // Note: don't remove all pointers + // Can restart without having to reinitiate all of them + this.parent.releasePointerCapture(event.pointerId); + removePointer(this.pointers, event); + this.origX = undefined; + this.origY = undefined; + this.startClientX = undefined; + this.startClientY = undefined; + + this.listenerController?.abort(); + } + + getPan() { + return { x: this.x, y: this.y }; + } + + getScale() { + return this.scale; + } + + getOptions() { + return { ...this.options }; + } +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +export * from "./types"; +export default Panzoom; diff --git a/src/lib/components/svg-view/panzoom/pointers.ts b/src/lib/components/svg-view/panzoom/pointers.ts new file mode 100644 index 00000000..91501d39 --- /dev/null +++ b/src/lib/components/svg-view/panzoom/pointers.ts @@ -0,0 +1,70 @@ +/** + * Utilites for working with multiple pointer events + */ + +function findEventIndex(pointers: PointerEvent[], event: PointerEvent) { + let i = pointers.length; + while (i--) { + if (pointers[i].pointerId === event.pointerId) { + return i; + } + } + return -1; +} + +export function addPointer(pointers: PointerEvent[], event: PointerEvent) { + const i = findEventIndex(pointers, event); + // Update if already present + if (i > -1) { + pointers.splice(i, 1); + } + pointers.push(event); +} + +export function removePointer(pointers: PointerEvent[], event: PointerEvent) { + const i = findEventIndex(pointers, event); + if (i > -1) { + pointers.splice(i, 1); + } +} + +/** + * Calculates a center point between + * the given pointer events, for panning + * with multiple pointers. + */ +export function getMiddle(pointers: PointerEvent[]) { + // Copy to avoid changing by reference + pointers = [...pointers]; + const firstPointer = pointers.pop(); + if (firstPointer === undefined) { + return { clientX: 0, clientY: 0 }; + } + let location: Pick = firstPointer; + for (const pointer of pointers) { + location = { + clientX: + (pointer.clientX - location.clientX) / 2 + location.clientX, + clientY: + (pointer.clientY - location.clientY) / 2 + location.clientY, + }; + } + return location; +} + +/** + * Calculates the distance between two points + * for pinch zooming. + * Limits to the first 2 + */ +export function getDistance(pointers: PointerEvent[]) { + if (pointers.length < 2) { + return 0; + } + const event1 = pointers[0]; + const event2 = pointers[1]; + return Math.sqrt( + Math.pow(Math.abs(event2.clientX - event1.clientX), 2) + + Math.pow(Math.abs(event2.clientY - event1.clientY), 2), + ); +} diff --git a/src/lib/components/svg-view/panzoom/types.ts b/src/lib/components/svg-view/panzoom/types.ts new file mode 100644 index 00000000..a4fa6c04 --- /dev/null +++ b/src/lib/components/svg-view/panzoom/types.ts @@ -0,0 +1,326 @@ +export type PanzoomEvent = + | "panzoomstart" + | "panzoomchange" + | "panzoompan" + | "panzoomzoom" + | "panzoomreset" + | "panzoomend"; + +export interface PanzoomEventDetail { + x: number; + y: number; + scale: number; + originalEvent: PointerEvent | TouchEvent | MouseEvent | undefined; +} + +export interface PanzoomChangeEvent extends Event { + detail: PanzoomEventDetail; +} + +export interface MiscOptions { + /** Whether to animate transitions */ + animate: boolean; + /** + * This option treats the Panzoom element's parent + * as a canvas. Effectively, Panzoom binds the + * down handler to the parent instead of the Panzoom + * element, so that pointer events anywhere on the "canvas" + * moves its children. See issue #472. + * + * **Note**: setting this option to `true` also changes + * where the `cursor` style is applied (i.e. the parent). + */ + canvas: boolean; + /** + * Add elements to this array that should be excluded + * from Panzoom handling. + * Ancestors of event targets are also checked. + * e.g. links and buttons that should not propagate the click event. + */ + exclude: Element[]; + /** + * Add this class to any element within the Panzoom element + * that you want to exclude from Panzoom handling. That + * element's children will also be excluded. + * e.g. links and buttons that should not propagate the click event. + */ + excludeClass: string; + /** + * `force` should be used sparingly to temporarily + * override and ignore options such as disablePan, + * disableZoom, and panOnlyWhenZoomed. + * This option cannot be passed to the + * Panzoom constructor or setOptions (to avoid + * setting this option globally). + * + * ```js + * // Overrides disablePan and panOnlyWhenZoomed + * panzoom.pan(50, 100, { force: true }) + * // Overrides disableZoom + * panzoom.zoom(1, { force: true }) + * ``` + */ + force: boolean; + /** + * On the first pointer event, when panning starts, + * the default Panzoom behavior is to call + * `event.preventDefault()` and `event.stopPropagation()` + * on that event. The former is almost certainly a necessity; + * the latter enables Panzoom elements within Panzoom elements. + * + * But there are some cases where the default is + * not the desired behavior. Set this option to override that behavior. + * + * ```js + * // Only call preventDefault() + * Panzoom(elem, { + * handleStartEvent: (event) => { + * event.preventDefault() + * } + * }) + * // Do nothing. + * // This can change dragging behavior on mobile. + * Panzoom(elem, { + * handleStartEvent: () => {} + * }) + * ``` + */ + handleStartEvent: (event: Event) => void; + /** + * Set to true to enable panning during pinch zoom. + * Note: this is zooming to a point and panning in the same + * frame. In other words, the zoom has not yet painted and + * therefore the pan is working with old dimensions. + * Essentially, it may be best to avoid using this option + * when using contain. + * + * Related issues: + * https://github.com/timmywil/panzoom/issues/512 + * https://github.com/timmywil/panzoom/issues/606 + */ + pinchAndPan: boolean; + /** Silence all events */ + silent: boolean; + /** X Value used to set the beginning transform */ + startX: number; + /** Y Value used to set the beginning transform */ + startY: number; + /** Scale used to set the beginning transform */ + startScale: number; +} + +export interface PanOnlyOptions { + /** + * Contain the panzoom element either + * inside or outside the parent. + * Inside: The panzoom element is smaller + * than its parent and cannot be panned + * to the outside. + * Outside: The panzoom element is larger + * than its parent and cannot be panned + * to the inside. In other words, no + * empty space around the element will be shown. + * + * **Note**: the containment pan adjustment is not affected by the `disablePan` option. + */ + contain?: "inside" | "outside"; + /** + * Disable panning functionality. + * Note: disablePan does not affect focal point zooming or the contain option. + * The element will still pan accordingly. + */ + disablePan: boolean; + /** Pan only on the Y axis */ + disableXAxis: boolean; + /** Pan only on the X axis */ + disableYAxis: boolean; + /** When passing x and y values to .pan(), treat the values as relative to their current values */ + relative: boolean; + /** Disable panning while the scale is equal to the starting value */ + panOnlyWhenZoomed: boolean; + /** + * Round x and y values to whole numbers. + * This can help prevent images and text from looking blurry, + * but the higher the scale, the more it becomes + * necessary to use fractional pixels. + * Use your own judgment on how much to limit + * zooming in when using this option. + */ + roundPixels: boolean; +} + +export interface ZoomOnlyOptions { + /** Disable zooming functionality */ + disableZoom: boolean; + /** + * Zoom to the given point on the panzoom element. + * This point is expected to be relative to + * the panzoom element's dimensions and is unrelated + * to the parent dimensions. + */ + focal?: { x: number; y: number }; + /** The minimum scale when zooming */ + minScale: number; + /** The maximum scale when zooming */ + maxScale: number; + /** The step affects zoom calculation when zooming with a mouse wheel, when pinch zooming, or when using zoomIn/zoomOut */ + step: number; +} + +export type PanOptions = MiscOptions & PanOnlyOptions; +export type ZoomOptions = MiscOptions & ZoomOnlyOptions; +export type PanzoomOptions = PanOptions & ZoomOptions & MiscOptions; + +export interface CurrentValues { + x: number; + y: number; + scale: number; +} + +export interface PanzoomObject { + /** Get the current x/y translation */ + getPan: () => { x: number; y: number }; + /** Get the current scale */ + getScale: () => number; + /** Returns a _copy_ of the current options object */ + getOptions: () => PanzoomOptions; + /** + * handleDown, handleMove, and handleUp + * are the exact event handlers that Panzoom + * binds to pointer events. They are exposed + * in case you prefer to bind your own events + * or extend them. + * Note that move and up are bound to the document, + * not the Panzoom element. Only the down event + * is bound to the Panzoom element. + * To avoid double-binding, also set noBind to true. + * + * ```js + * const panzoom = Panzoom(elem, { noBind: true }) + * elem.addEventListener('pointerdown', (event) => { + * console.log(event) + * panzoom.handleDown(event) + * }) + * document.addEventListener('pointermove', panzoom.handleMove) + * document.addEventListener('pointerup', panzoom.handleUp) + * ``` + */ + handleDown: (event: PointerEvent) => void; + handleMove: (event: PointerEvent) => void; + handleUp: (event: PointerEvent) => void; + /** + * Pan the Panzoom element to the given x and y coordinates + * + * ```js + * // Translates the element to 50px, 100px + * panzoom.pan(50, 100) + * // Pans the element right 10px and down 10px from its current position + * panzoom.pan(10, 10, { relative: true }) + * ``` + */ + pan: ( + x: number | string, + y: number | string, + panOptions?: Partial, + ) => CurrentValues; + /** + * Reset the pan and zoom to startX, startY, and startScale. + * Animates by default, ignoring the global option. + * Pass `{ animate: false }` to override. + * Reset ignores the `disablePan`, `disableZoom`, and `panOnlyWhenZoomed` options. + * Pass `{ force: false }` to override. + * + * ```js + * panzoom.reset() + * panzoom.reset({ animate: false }) + * ``` + */ + reset: (resetOptions?: Partial) => CurrentValues; + /** + * Change any number of options on a Panzoom instance. + * Setting some options will have side-effects. + */ + setOptions: (options?: Partial) => void; + /** + * Zoom the Panzoom element to the given scale + * + * ```js + * panzoom.zoom(2.2) + * panzoom.zoom(2.2, { animate: true }) + * ``` + */ + zoom: ( + scale: number, + zoomOptions?: Partial, + originalEvent?: PanzoomEventDetail["originalEvent"], + ) => CurrentValues | undefined; + /** + * Zoom in using the predetermined increment set in options. + * Animates by default, ignoring the global option. + * Pass `{ animate: false }` to override. + * + * ```js + * panzoom.zoomIn() + * panzoom.zoomIn({ animate: false }) + * ``` + */ + zoomIn: (zoomOptions?: Partial) => CurrentValues | undefined; + /** + * Zoom out using the predetermined increment set in options. + * Animates by default, ignoring the global option. + * Pass `{ animate: false }` to override. + * + * ```js + * panzoom.zoomOut() + * panzoom.zoomOut({ animate: false }) + * ``` + */ + zoomOut: (zoomOptions?: Partial) => CurrentValues | undefined; + /** + * Zoom the Panzoom element to a focal point using + * the given pointer/touch/mouse event or constructed point. + * The clientX/clientY values should be calculated + * the same way as a `pointermove` event on the Panzoom element's parent. + * + * ```js + * panzoom.zoomToPoint(1.2, pointerEvent) + * ``` + */ + zoomToPoint: ( + scale: number, + point: { clientX: number; clientY: number }, + zoomOptions?: Partial, + ) => CurrentValues | undefined; + /** + * Zoom the Panzoom element to a focal point using the given WheelEvent + * + * This is a convenience function that may not handle all use cases. + * Other cases should handroll solutions using the `zoomToPoint` + * method or the `zoom` method's focal option. + * + * **Notes**: + * + * - the focal point zooming pan adjustment is + * not affected by the `disablePan` option. + * - animate should not be used when zooming with the wheel, + * and is therefore always disabled. + * + * ```js + * // Bind to mousewheel + * elem.parentElement.addEventListener('wheel', panzoom.zoomWithWheel) + * // Bind to shift+mousewheel + * elem.parentElement.addEventListener('wheel', function(event) { + * if (!event.shiftKey) return + * // Panzoom will automatically use `deltaX` here instead + * // of `deltaY`. On a mac, the shift modifier usually + * // translates to horizontal scrolling, but Panzoom assumes + * // the desired behavior is zooming. + * panzoom.zoomWithWheel(event) + * }) + * ``` + */ + zoomWithWheel: ( + event: WheelEvent, + zoomOptions?: Partial, + ) => CurrentValues | undefined; +} diff --git a/src/lib/globalState/activeModel.ts b/src/lib/globalState/activeModel.ts new file mode 100644 index 00000000..7e56a72f --- /dev/null +++ b/src/lib/globalState/activeModel.ts @@ -0,0 +1,131 @@ +import { writable, type Writable } from "svelte/store"; +import type { iLocation } from "$lib/interfaces/iLocation"; +import type { iEdge } from "$lib/interfaces/iEdge"; +import { + LocationType, + PropertyType, + Status, + Urgency, +} from "$lib/classes/automaton"; + +export class ActiveModel { + constructor( + private _locations: Record = {}, + private _edges: iEdge[] = [], + ) {} + + get locations() { + return this._locations; + } + + get edges() { + return this._edges; + } +} + +export const activeModel: Writable = writable(new ActiveModel()); + +// TODO: This is just adding temporary data +activeModel.set( + new ActiveModel( + { + "1": { + color: "#ff0000", + id: "1", + invariant: { fn: "c >= 8", position: { x: 100, y: 300 } }, + nickname: { name: "nickname", position: { x: 100, y: 300 } }, + position: { x: 100, y: 300 }, + type: LocationType.INITIAL, + urgency: Urgency.NORMAL, + }, + "2": { + color: "#ff0000", + id: "2", + invariant: { fn: "c >= 8", position: { x: 300, y: 300 } }, + nickname: { name: "nickname", position: { x: 300, y: 300 } }, + position: { x: 300, y: 300 }, + type: LocationType.INITIAL, + urgency: Urgency.NORMAL, + }, + "3": { + color: "#00ff00", // Example color for location 3 + id: "3", + invariant: { fn: "c >= 8", position: { x: 500, y: 500 } }, + nickname: { + name: "Location numba drei", + position: { x: 500, y: 500 }, + }, // Example nickname + position: { x: 500, y: 500 }, // Example position + type: LocationType.NORMAL, // Example location type + urgency: Urgency.NORMAL, // Example urgency + }, + "4": { + color: "#0000ff", // Example color for location 4 + id: "4", + invariant: { fn: "c >= 8", position: { x: 700, y: 320 } }, + nickname: { + name: "nickname4laefpefleapflp", + position: { x: 700, y: 320 }, + }, // Example nickname + position: { x: 700, y: 300 }, // Example position + type: LocationType.INITIAL, // Example location type + urgency: Urgency.NORMAL, // Example urgency + }, + }, + + [ + { + guard: "c >= 8", + id: "1", + nails: [], + sourceLocation: "1", + targetLocation: "2", + status: Status.INPUT, + sync: "", + update: "", + }, + { + guard: "c >= 8", + id: "1", + nails: [], + sourceLocation: "1", + targetLocation: "2", + status: Status.INPUT, + sync: "", + update: "", + }, + // Add more edge objects here for additional connections + { + guard: "c < 8", + id: "2", + sourceLocation: "1", + targetLocation: "3", + status: Status.OUTPUT, + sync: "", + update: "", + nails: [ + { + position: { x: 350, y: 300 }, + property: { + type: PropertyType.SYNCHRONIZATION, + position: { + x: 350, + y: 300, + }, + }, + }, + ], + }, + { + guard: "c >= 10", + id: "3", + nails: [], + sourceLocation: "3", + targetLocation: "4", + status: Status.OUTPUT, + sync: "", + update: "", + }, + ], + ), +); diff --git a/src/lib/globalState/scaleStore.ts b/src/lib/globalState/scaleStore.ts new file mode 100644 index 00000000..79c6051e --- /dev/null +++ b/src/lib/globalState/scaleStore.ts @@ -0,0 +1,7 @@ +import { writable, type Writable } from "svelte/store"; + +/** + * Defines the current scaling of the main SVG view. + * We need to know the current scaling when calculating the position of locations based on the mouse position. + */ +export const scale: Writable = writable(1); diff --git a/src/lib/interfaces/iEdge.ts b/src/lib/interfaces/iEdge.ts new file mode 100644 index 00000000..373fb31e --- /dev/null +++ b/src/lib/interfaces/iEdge.ts @@ -0,0 +1,14 @@ +import type { Status } from "$lib/classes/automaton"; +import type { iNail } from "./iNail"; + +export interface iEdge { + id: string; + sourceLocation: string; + targetLocation: string; + status: Status; + guard: string; + update: string; + sync: string; + + nails: iNail[]; +} diff --git a/src/lib/interfaces/iInvariant.ts b/src/lib/interfaces/iInvariant.ts new file mode 100644 index 00000000..fdbaf93c --- /dev/null +++ b/src/lib/interfaces/iInvariant.ts @@ -0,0 +1,14 @@ +import type { iPoint } from "./iPoint"; + +export interface iInvariant { + /** + * The invariant function + * ex c >= 8 + * */ + fn: string; + + /** + * The position of the invariant + * */ + position: iPoint; +} diff --git a/src/lib/interfaces/iLocation.ts b/src/lib/interfaces/iLocation.ts new file mode 100644 index 00000000..aacfce46 --- /dev/null +++ b/src/lib/interfaces/iLocation.ts @@ -0,0 +1,42 @@ +import type { iPoint } from "./iPoint"; +import type { iNickname } from "./iNickname"; +import type { iInvariant } from "./iInvariant"; +import type { LocationType } from "../classes/automaton/LocationType"; +import type { Urgency } from "../classes/automaton/Urgency"; + +export interface iLocation { + /** + * The id of the Location + * */ + id: string; + + /** + * The position of the Location + * */ + position: iPoint; + + /** + * The Nickname of the Location + * */ + nickname: iNickname; + + /** + * The Invariant of the Location + * */ + invariant: iInvariant; + + /** + * The Type of the Location + * */ + type: LocationType; + + /** + * The Urgency of the Location + * */ + urgency: Urgency; + + /** + * The Color of the Location + * */ + color: string; +} diff --git a/src/lib/interfaces/iNail.ts b/src/lib/interfaces/iNail.ts new file mode 100644 index 00000000..36c7885b --- /dev/null +++ b/src/lib/interfaces/iNail.ts @@ -0,0 +1,7 @@ +import type { iProperty } from "./iProperty"; +import type { iPoint } from "./iPoint"; + +export interface iNail { + position: iPoint; + property: iProperty; +} diff --git a/src/lib/interfaces/iNickname.ts b/src/lib/interfaces/iNickname.ts new file mode 100644 index 00000000..6bfd2266 --- /dev/null +++ b/src/lib/interfaces/iNickname.ts @@ -0,0 +1,13 @@ +import type { iPoint } from "./iPoint"; + +export interface iNickname { + /** + * The name of the Nickname + * */ + name: string; + + /** + * The position of the Nickname + * */ + position: iPoint; +} diff --git a/src/lib/interfaces/iPoint.ts b/src/lib/interfaces/iPoint.ts new file mode 100644 index 00000000..1d47f327 --- /dev/null +++ b/src/lib/interfaces/iPoint.ts @@ -0,0 +1,4 @@ +export interface iPoint { + x: number; + y: number; +} diff --git a/src/lib/interfaces/iProperty.ts b/src/lib/interfaces/iProperty.ts new file mode 100644 index 00000000..b1a54fcd --- /dev/null +++ b/src/lib/interfaces/iProperty.ts @@ -0,0 +1,14 @@ +import type { PropertyType } from "$lib/classes/automaton/PropertyType"; +import type { iPoint } from "./iPoint"; + +export interface iProperty { + /** + * The type of property + * */ + type: PropertyType; + + /** + * The position of the property + * */ + position: iPoint; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 78ccdb98..bc05a0fe 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,13 @@ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c85a536c..f7f243ce 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,9 +1,8 @@ - +
{#if $project === undefined} @@ -75,7 +76,10 @@
-
+
@@ -100,9 +104,7 @@
-

Canvas

- - +
nav { height: 5em; - background-color: slategrey; + border: var(--main-navigationbar-border); flex-shrink: 0; } #main-nav { + color: var(--navigationbar-text-color); + background-color: var(--main-navigationbar-color); height: 2.5em; min-height: 2.5em; } @@ -148,13 +152,19 @@ .inner-nav1, .inner-nav3 { - background-color: slategrey; - box-shadow: lightslategray 0px 0px 1em; + background-color: var(--main-navigationbar-color); + border: var(--main-innernavigationbar-border); } .inner-nav2 { - background-color: lightslategrey; - box-shadow: slategrey 0px 0px 1em; + background-color: var(--canvas-topbar-color); + border: none; + } + + .inner-nav1, + .inner-nav2, + .inner-nav3 { + color: var(--navigationbar-text-color); } .global-dec { @@ -183,14 +193,13 @@ } .sidePanel { - background-color: whitesmoke; flex-basis: 10em; overflow: hidden; - display: flex; - flex-direction: column; + background-color: var(--background-color); } .sidePanelContent { + color: var(--sidebar-text-color); height: 100%; width: 100%; overflow-y: auto; @@ -200,13 +209,19 @@ .resizer { background-color: black; - flex-basis: 0.3em; + flex-basis: 0.1em; cursor: col-resize; } .canvas { - background-color: whitesmoke; - flex: 1; - width: 0; + color: var(--canvas-text-color); + background-color: var(--background-color); + flex-grow: 1; + } + + .canvas, + .sidePanel { + display: flex; + flex-direction: column; } diff --git a/tests/svgViewTests.test.ts b/tests/svgViewTests.test.ts new file mode 100644 index 00000000..b4d5e3e1 --- /dev/null +++ b/tests/svgViewTests.test.ts @@ -0,0 +1,98 @@ +import type { iPoint } from "$lib/interfaces/iPoint"; +import { test, expect } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.click("#start-new-project"); +}); + +test("drag and drop a location to a new position", async ({ page }) => { + const svg = page.locator("#node-1"); + + // Get the origiganl position of the element + const svgY = parseInt((await svg.getAttribute("cy")) ?? "1"); + const svgX = parseInt((await svg.getAttribute("cx")) ?? "1"); + + /* Expected value of the element (there is an offset of 20 because the + * element is dragged from its edge in the padding of the svg) + */ + const expectedX = svgX; + const expectedY = svgY + 80; + + console.log(svgX, svgY); + //Offset by 80 + await svg.dragTo(svg, { targetPosition: { x: 20, y: 100 }, force: true }); + + // New values x and y position of the element + const resultX = parseInt((await svg.getAttribute("cx")) ?? "1"); + const resultY = parseInt((await svg.getAttribute("cy")) ?? "1"); + + // Check if the element has moved + expect(resultX).toBe(expectedX); + expect(resultY).toBe(expectedY); +}); + +test("drag and drop a nail", async ({ page }) => { + const svg = page.locator("#node-\\!"); + + // Get the origiganl position of the element + const svgY = parseInt((await svg.getAttribute("cy")) ?? "1"); + const svgX = parseInt((await svg.getAttribute("cx")) ?? "1"); + + /* Expected value of the element (there is an offset of 10 because the + * element is dragged from its edge in the padding of the svg) + */ + const expectedX = svgX; + const expectedY = svgY + 80; + + console.log(svgX, svgY); + //Offset by 80 + await svg.dragTo(svg, { targetPosition: { x: 10, y: 90 }, force: true }); + + // New values x and y position of the element + const resultX = parseInt((await svg.getAttribute("cx")) ?? "1"); + const resultY = parseInt((await svg.getAttribute("cy")) ?? "1"); + + // Check if the element has moved + expect(resultX).toBe(expectedX); + expect(resultY).toBe(expectedY); +}); + +test("see if the svg line moves with the nodes", async ({ page }) => { + const location = page.locator("#node-3"); + + // get the line location between the location and the nail + const line = page.locator("#edge-OUTPUT-1"); + + // get the original location of the line + const oldSourceLocation: iPoint = { + x: parseInt((await line.getAttribute("x1")) ?? "1"), + y: parseInt((await line.getAttribute("y1")) ?? "1"), + }; + + const oldTargetLocation: iPoint = { + x: parseInt((await line.getAttribute("x2")) ?? "1"), + y: parseInt((await line.getAttribute("y2")) ?? "1"), + }; + + // move the location by an offset of 80 + await location.dragTo(location, { + targetPosition: { x: 20, y: 100 }, + force: true, + }); + + // get the new location of the line + const newSourceLocation: iPoint = { + x: parseInt((await line.getAttribute("x1")) ?? "1"), + y: parseInt((await line.getAttribute("y1")) ?? "1"), + }; + + const newTargetLocation: iPoint = { + x: parseInt((await line.getAttribute("x2")) ?? "1"), + y: parseInt((await line.getAttribute("y2")) ?? "1"), + }; + + // check if the line has moved (target is the location) + expect(newSourceLocation).toEqual(oldSourceLocation); + expect(newTargetLocation).not.toEqual(oldTargetLocation); +});