mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-20 03:18:06 +08:00
Generalize and rename overlays as "floating menus"
Progress towards #135.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="popover-button">
|
||||
<IconButton :icon="icon" :size="16" @click="clickButton" />
|
||||
<Popover :direction="PopoverDirection.Bottom" ref="popover">
|
||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Bottom" ref="floatingMenu">
|
||||
<slot></slot>
|
||||
</Popover>
|
||||
</FloatingMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.popover {
|
||||
.floating-menu {
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import IconButton from "./IconButton.vue";
|
||||
import Popover, { PopoverDirection } from "../overlays/Popover.vue";
|
||||
import FloatingMenu, { MenuDirection, MenuType } from "../floating-menus/FloatingMenu.vue";
|
||||
|
||||
export enum PopoverButtonIcon {
|
||||
"DropdownArrow" = "DropdownArrow",
|
||||
@@ -50,7 +50,7 @@ export enum PopoverButtonIcon {
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Popover,
|
||||
FloatingMenu,
|
||||
IconButton,
|
||||
},
|
||||
props: {
|
||||
@@ -58,12 +58,13 @@ export default defineComponent({
|
||||
},
|
||||
methods: {
|
||||
clickButton() {
|
||||
(this.$refs.popover as typeof Popover).setOpen();
|
||||
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
PopoverDirection,
|
||||
MenuDirection,
|
||||
MenuType,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
299
client/web/src/components/widgets/floating-menus/ColorPicker.vue
Normal file
299
client/web/src/components/widgets/floating-menus/ColorPicker.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="color-picker">
|
||||
<div class="saturation-picker" ref="saturationPicker" data-picker-action="MoveSaturation" @pointerdown="onPointerDown">
|
||||
<div ref="saturationCursor" class="selection-circle"></div>
|
||||
</div>
|
||||
<div class="hue-picker" ref="huePicker" data-picker-action="MoveHue" @pointerdown="onPointerDown">
|
||||
<div ref="hueCursor" class="selection-pincers"></div>
|
||||
</div>
|
||||
<div class="opacity-picker" ref="opacityPicker" data-picker-action="MoveOpacity" @pointerdown="onPointerDown">
|
||||
<div ref="opacityCursor" class="selection-pincers"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.color-picker {
|
||||
--saturation-picker-hue: #ff0000;
|
||||
--opacity-picker-color: #ff0000;
|
||||
display: flex;
|
||||
|
||||
.saturation-picker {
|
||||
width: 256px;
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--saturation-picker-hue));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.saturation-picker,
|
||||
.hue-picker,
|
||||
.opacity-picker {
|
||||
height: 256px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hue-picker,
|
||||
.opacity-picker {
|
||||
width: 24px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hue-picker {
|
||||
background-blend-mode: screen;
|
||||
background: linear-gradient(to top, #ff0000ff 16.666%, #ff000000 33.333%, #ff000000 66.666%, #ff0000ff 83.333%),
|
||||
linear-gradient(to top, #00ff0000 0%, #00ff00ff 16.666%, #00ff00ff 50%, #00ff0000 66.666%), linear-gradient(to top, #0000ff00 33.333%, #0000ffff 50%, #0000ffff 83.333%, #0000ff00 100%);
|
||||
}
|
||||
|
||||
.opacity-picker {
|
||||
background: linear-gradient(to bottom, var(--opacity-picker-color), transparent);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
position: relative;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-circle {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
top: 0%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-sizing: border-box;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-pincers {
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent #000000;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent #000000 transparent transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { clamp, hsvToRgb, rgbToHsv, isRGB } from "../../../lib/utils";
|
||||
|
||||
const enum ColorPickerState {
|
||||
Idle = "Idle",
|
||||
MoveHue = "MoveHue",
|
||||
MoveOpacity = "MoveOpacity",
|
||||
MoveSaturation = "MoveSaturation",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
color: {
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
state: ColorPickerState.Idle,
|
||||
// Disable proxy on this object
|
||||
// https://v3.vuejs.org/api/options-data.html#data-2
|
||||
// eslint-disable-next-line vue/no-reserved-keys
|
||||
_: {
|
||||
colorPicker: {
|
||||
color: { h: 0, s: 0, v: 0, a: 1 },
|
||||
hue: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
opacity: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
saturation: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch("color", this.updateColor, { immediate: true });
|
||||
},
|
||||
unmounted() {
|
||||
this.removeEvents();
|
||||
},
|
||||
methods: {
|
||||
addEvents() {
|
||||
document.addEventListener("pointermove", this.onPointerMove);
|
||||
document.addEventListener("pointerup", this.onPointerUp);
|
||||
},
|
||||
|
||||
removeEvents() {
|
||||
document.removeEventListener("pointermove", this.onPointerMove);
|
||||
document.removeEventListener("pointerup", this.onPointerUp);
|
||||
},
|
||||
|
||||
getRef<T>(name: string) {
|
||||
return this.$refs[name] as T;
|
||||
},
|
||||
|
||||
onPointerDown(e: PointerEvent) {
|
||||
if (!(e.currentTarget instanceof Element)) return;
|
||||
const picker = e.currentTarget.getAttribute("data-picker-action");
|
||||
this.state = (() => {
|
||||
switch (picker) {
|
||||
case "MoveHue":
|
||||
return ColorPickerState.MoveHue;
|
||||
case "MoveOpacity":
|
||||
return ColorPickerState.MoveOpacity;
|
||||
case "MoveSaturation":
|
||||
return ColorPickerState.MoveSaturation;
|
||||
default:
|
||||
return ColorPickerState.Idle;
|
||||
}
|
||||
})();
|
||||
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.addEvents();
|
||||
this.updateRects();
|
||||
this.onPointerMove(e);
|
||||
}
|
||||
},
|
||||
|
||||
onPointerMove(e: PointerEvent) {
|
||||
const { colorPicker } = this.$data._;
|
||||
|
||||
if (this.state === ColorPickerState.MoveHue) {
|
||||
this.setHuePosition(e.clientY - colorPicker.hue.rect.top);
|
||||
} else if (this.state === ColorPickerState.MoveOpacity) {
|
||||
this.setOpacityPosition(e.clientY - colorPicker.opacity.rect.top);
|
||||
} else if (this.state === ColorPickerState.MoveSaturation) {
|
||||
this.setSaturationPosition(e.clientX - colorPicker.saturation.rect.left, e.clientY - colorPicker.saturation.rect.top);
|
||||
}
|
||||
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.updateHue();
|
||||
this.$emit("update:color", hsvToRgb(colorPicker.color));
|
||||
}
|
||||
},
|
||||
|
||||
onPointerUp() {
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.state = ColorPickerState.Idle;
|
||||
this.removeEvents();
|
||||
}
|
||||
},
|
||||
|
||||
updateRects() {
|
||||
const { colorPicker } = this.$data._;
|
||||
|
||||
const saturationPicker = this.getRef<HTMLDivElement>("saturationPicker");
|
||||
const saturation = saturationPicker.getBoundingClientRect();
|
||||
colorPicker.saturation.rect.width = saturation.width;
|
||||
colorPicker.saturation.rect.height = saturation.height;
|
||||
colorPicker.saturation.rect.left = saturation.left;
|
||||
colorPicker.saturation.rect.top = saturation.top;
|
||||
|
||||
const huePicker = this.getRef<HTMLDivElement>("huePicker");
|
||||
const hue = huePicker.getBoundingClientRect();
|
||||
colorPicker.hue.rect.width = hue.width;
|
||||
colorPicker.hue.rect.height = hue.height;
|
||||
colorPicker.hue.rect.left = hue.left;
|
||||
colorPicker.hue.rect.top = hue.top;
|
||||
|
||||
const opacityPicker = this.getRef<HTMLDivElement>("opacityPicker");
|
||||
const opacity = opacityPicker.getBoundingClientRect();
|
||||
colorPicker.opacity.rect.width = opacity.width;
|
||||
colorPicker.opacity.rect.height = opacity.height;
|
||||
colorPicker.opacity.rect.left = opacity.left;
|
||||
colorPicker.opacity.rect.top = opacity.top;
|
||||
},
|
||||
|
||||
setSaturationPosition(x: number, y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const saturationCursor = this.getRef<HTMLDivElement>("saturationCursor");
|
||||
const saturationPosition = [clamp(x, 0, colorPicker.saturation.rect.width), clamp(y, 0, colorPicker.saturation.rect.height)];
|
||||
saturationCursor.style.transform = `translate(${saturationPosition[0]}px, ${saturationPosition[1]}px)`;
|
||||
colorPicker.color.s = saturationPosition[0] / colorPicker.saturation.rect.width;
|
||||
colorPicker.color.v = (1 - saturationPosition[1] / colorPicker.saturation.rect.height) * 255;
|
||||
},
|
||||
|
||||
setHuePosition(y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const hueCursor = this.getRef<HTMLDivElement>("hueCursor");
|
||||
const huePosition = clamp(y, 0, colorPicker.hue.rect.height);
|
||||
hueCursor.style.transform = `translateY(${huePosition}px)`;
|
||||
colorPicker.color.h = clamp(1 - huePosition / colorPicker.hue.rect.height);
|
||||
},
|
||||
|
||||
setOpacityPosition(y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const opacityCursor = this.getRef<HTMLDivElement>("opacityCursor");
|
||||
const opacityPosition = clamp(y, 0, colorPicker.opacity.rect.height);
|
||||
opacityCursor.style.transform = `translateY(${opacityPosition}px)`;
|
||||
colorPicker.color.a = clamp(1 - opacityPosition / colorPicker.opacity.rect.height);
|
||||
},
|
||||
|
||||
updateHue() {
|
||||
const { colorPicker } = this.$data._;
|
||||
let color = hsvToRgb({ h: colorPicker.color.h, s: 1, v: 255, a: 1 });
|
||||
this.$el.style.setProperty("--saturation-picker-hue", `rgb(${color.r}, ${color.g}, ${color.b})`);
|
||||
color = hsvToRgb(colorPicker.color);
|
||||
this.$el.style.setProperty("--opacity-picker-color", `rgb(${color.r}, ${color.g}, ${color.b})`);
|
||||
},
|
||||
|
||||
updateColor() {
|
||||
if (this.state !== ColorPickerState.Idle) return;
|
||||
const { color } = this;
|
||||
if (!isRGB(color)) return;
|
||||
const { colorPicker } = this.$data._;
|
||||
colorPicker.color = rgbToHsv(color);
|
||||
this.updateRects();
|
||||
this.setSaturationPosition(colorPicker.color.s * colorPicker.saturation.rect.width, (1 - colorPicker.color.v / 255) * colorPicker.saturation.rect.height);
|
||||
this.setOpacityPosition((1 - colorPicker.color.a) * colorPicker.opacity.rect.height);
|
||||
this.setHuePosition((1 - colorPicker.color.h) * colorPicker.hue.rect.height);
|
||||
this.updateHue();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open">
|
||||
<div class="tail" v-if="type === MenuType.Popover"></div>
|
||||
<div class="floating-menu-container" ref="floatingMenuContainer">
|
||||
<div class="floating-menu-content" ref="floatingMenuContent">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
--floating-menu-content-border-radius: 0 0 4px 4px;
|
||||
|
||||
.tail {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
// 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;
|
||||
}
|
||||
|
||||
.floating-menu-container {
|
||||
display: flex;
|
||||
|
||||
.floating-menu-content {
|
||||
background: var(--floating-menu-opacity-color-2-mildblack);
|
||||
box-shadow: var(--color-0-black) 0 0 4px;
|
||||
border-radius: var(--floating-menu-content-border-radius);
|
||||
color: var(--color-e-nearwhite);
|
||||
font-size: inherit;
|
||||
padding: 8px;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
&.popover {
|
||||
--floating-menu-content-offset: 10px;
|
||||
--floating-menu-content-border-radius: 4px;
|
||||
}
|
||||
|
||||
&.top,
|
||||
&.bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.top .tail {
|
||||
border-width: 8px 6px 0 6px;
|
||||
border-color: var(--floating-menu-opacity-color-2-mildblack) transparent transparent transparent;
|
||||
margin-left: -6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&.bottom .tail {
|
||||
border-width: 0 6px 8px 6px;
|
||||
border-color: transparent transparent var(--floating-menu-opacity-color-2-mildblack) transparent;
|
||||
margin-left: -6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.left .tail {
|
||||
border-width: 6px 0 6px 8px;
|
||||
border-color: transparent transparent transparent var(--floating-menu-opacity-color-2-mildblack);
|
||||
margin-top: -6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&.right .tail {
|
||||
border-width: 6px 8px 6px 0;
|
||||
border-color: transparent var(--floating-menu-opacity-color-2-mildblack) transparent transparent;
|
||||
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>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export enum MenuDirection {
|
||||
Top = "Top",
|
||||
Bottom = "Bottom",
|
||||
Left = "Left",
|
||||
Right = "Right",
|
||||
}
|
||||
|
||||
export enum MenuType {
|
||||
Popover = "Popover",
|
||||
Dropdown = "Dropdown",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
direction: { type: String, default: MenuDirection.Bottom },
|
||||
type: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
open: false,
|
||||
mouseStillDown: false,
|
||||
MenuDirection,
|
||||
MenuType,
|
||||
};
|
||||
},
|
||||
updated() {
|
||||
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
|
||||
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
|
||||
const workspace = document.querySelector(".workspace-row");
|
||||
|
||||
if (floatingMenuContent && workspace) {
|
||||
const workspaceBounds = workspace.getBoundingClientRect();
|
||||
const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();
|
||||
|
||||
if (this.direction === MenuDirection.Left || this.direction === MenuDirection.Right) {
|
||||
const topOffset = floatingMenuBounds.top - workspaceBounds.top - 8;
|
||||
if (topOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${-topOffset}px)`;
|
||||
|
||||
const bottomOffset = workspaceBounds.bottom - floatingMenuBounds.bottom - 8;
|
||||
if (bottomOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${bottomOffset}px)`;
|
||||
}
|
||||
|
||||
if (this.direction === MenuDirection.Top || this.direction === MenuDirection.Bottom) {
|
||||
const leftOffset = floatingMenuBounds.left - workspaceBounds.left - 8;
|
||||
if (leftOffset < 0) floatingMenuContainer.style.transform = `translate(${-leftOffset}px, 0)`;
|
||||
|
||||
const rightOffset = workspaceBounds.right - floatingMenuBounds.right - 8;
|
||||
if (rightOffset < 0) floatingMenuContainer.style.transform = `translate(${rightOffset}px, 0)`;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setOpen() {
|
||||
this.open = true;
|
||||
},
|
||||
setClosed() {
|
||||
this.open = false;
|
||||
},
|
||||
mouseMoveHandler(e: MouseEvent) {
|
||||
const MOUSE_STRAY_DISTANCE = 100;
|
||||
|
||||
// Close the floating menu if the mouse has strayed far enough from its bounds
|
||||
if (this.isMouseEventOutsideFloatingMenu(e, MOUSE_STRAY_DISTANCE)) {
|
||||
this.setClosed();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const eventIncludesLmb = Boolean(e.buttons & 1);
|
||||
|
||||
// Clean up any messes from lost mouseup events
|
||||
if (!this.open && !eventIncludesLmb) {
|
||||
this.mouseStillDown = false;
|
||||
window.removeEventListener("mouseup", this.mouseUpHandler);
|
||||
}
|
||||
},
|
||||
mouseDownHandler(e: MouseEvent) {
|
||||
// Close the floating menu if the mouse clicked outside the floating menu (but within stray distance)
|
||||
if (this.isMouseEventOutsideFloatingMenu(e)) {
|
||||
this.setClosed();
|
||||
|
||||
// Track if the left mouse button is now down so its later click event can be canceled
|
||||
const eventIsForLmb = e.button === 0;
|
||||
if (eventIsForLmb) this.mouseStillDown = true;
|
||||
}
|
||||
},
|
||||
mouseUpHandler(e: MouseEvent) {
|
||||
const eventIsForLmb = e.button === 0;
|
||||
|
||||
if (this.mouseStillDown && eventIsForLmb) {
|
||||
// Clean up self
|
||||
this.mouseStillDown = false;
|
||||
window.removeEventListener("mouseup", this.mouseUpHandler);
|
||||
|
||||
// Prevent the click event from firing, which would normally occur right after this mouseup event
|
||||
window.addEventListener("click", this.clickHandlerCapture, true);
|
||||
}
|
||||
},
|
||||
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", this.clickHandlerCapture, true);
|
||||
},
|
||||
isMouseEventOutsideFloatingMenu(e: MouseEvent, extraDistanceAllowed = 0): boolean {
|
||||
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
|
||||
const floatingMenuBounds = floatingMenuContent.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;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
open(newState: boolean, oldState: boolean) {
|
||||
if (newState && !oldState) {
|
||||
// Close floating menu if mouse strays far enough away
|
||||
window.addEventListener("mousemove", this.mouseMoveHandler);
|
||||
|
||||
// Close floating menu if mouse is outside (but within stray distance)
|
||||
window.addEventListener("mousedown", this.mouseDownHandler);
|
||||
|
||||
// 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("mouseup", this.mouseUpHandler);
|
||||
}
|
||||
if (!newState && oldState) {
|
||||
window.removeEventListener("mousemove", this.mouseMoveHandler);
|
||||
window.removeEventListener("mousedown", this.mouseDownHandler);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -2,15 +2,15 @@
|
||||
<div class="swatch-pair">
|
||||
<div class="secondary swatch">
|
||||
<button @click="clickSecondarySwatch" ref="secondaryButton"></button>
|
||||
<Popover :direction="PopoverDirection.Right" horizontal ref="secondarySwatchPopover">
|
||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Right" horizontal ref="secondarySwatchFloatingMenu">
|
||||
<ColorPicker v-model:color="secondaryColor" />
|
||||
</Popover>
|
||||
</FloatingMenu>
|
||||
</div>
|
||||
<div class="primary swatch">
|
||||
<button @click="clickPrimarySwatch" ref="primaryButton"></button>
|
||||
<Popover :direction="PopoverDirection.Right" horizontal ref="primarySwatchPopover">
|
||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Right" horizontal ref="primarySwatchFloatingMenu">
|
||||
<ColorPicker v-model:color="primaryColor" />
|
||||
</Popover>
|
||||
</FloatingMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
.floating-menu {
|
||||
top: 50%;
|
||||
right: -2px;
|
||||
}
|
||||
@@ -68,26 +68,26 @@
|
||||
<script lang="ts">
|
||||
import { rgbToDecimalRgb } from "@/lib/utils";
|
||||
import { defineComponent } from "vue";
|
||||
import ColorPicker from "../../popovers/ColorPicker.vue";
|
||||
import Popover, { PopoverDirection } from "../overlays/Popover.vue";
|
||||
import ColorPicker from "../floating-menus/ColorPicker.vue";
|
||||
import FloatingMenu, { MenuDirection, MenuType } from "../floating-menus/FloatingMenu.vue";
|
||||
|
||||
const wasm = import("../../../../wasm/pkg");
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Popover,
|
||||
FloatingMenu,
|
||||
ColorPicker,
|
||||
},
|
||||
props: {},
|
||||
methods: {
|
||||
clickPrimarySwatch() {
|
||||
this.getRef<typeof Popover>("primarySwatchPopover").setOpen();
|
||||
this.getRef<typeof Popover>("secondarySwatchPopover").setClosed();
|
||||
this.getRef<typeof FloatingMenu>("primarySwatchFloatingMenu").setOpen();
|
||||
this.getRef<typeof FloatingMenu>("secondarySwatchFloatingMenu").setClosed();
|
||||
},
|
||||
|
||||
clickSecondarySwatch() {
|
||||
this.getRef<typeof Popover>("secondarySwatchPopover").setOpen();
|
||||
this.getRef<typeof Popover>("primarySwatchPopover").setClosed();
|
||||
this.getRef<typeof FloatingMenu>("secondarySwatchFloatingMenu").setOpen();
|
||||
this.getRef<typeof FloatingMenu>("primarySwatchFloatingMenu").setClosed();
|
||||
},
|
||||
|
||||
getRef<T>(name: string) {
|
||||
@@ -118,7 +118,8 @@ export default defineComponent({
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
PopoverDirection,
|
||||
MenuDirection,
|
||||
MenuType,
|
||||
primaryColor: { r: 0, g: 0, b: 0, a: 1 },
|
||||
secondaryColor: { r: 255, g: 255, b: 255, a: 1 },
|
||||
};
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
<template>
|
||||
<div class="popover" :class="direction.toLowerCase()" v-if="open">
|
||||
<div class="tail"></div>
|
||||
<div class="popover-container" ref="popoverContainer">
|
||||
<div class="popover-content" ref="popoverContent">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.popover {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: flex;
|
||||
// Overlays begin at a z-index of 1000
|
||||
z-index: 1000;
|
||||
|
||||
&.top,
|
||||
&.bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.tail {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
// Put the tail above the popover's shadow
|
||||
z-index: 1;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
|
||||
.top > & {
|
||||
border-width: 8px 6px 0 6px;
|
||||
border-color: var(--popover-opacity-color-2-mildblack) transparent transparent transparent;
|
||||
margin-left: -6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.bottom > & {
|
||||
border-width: 0 6px 8px 6px;
|
||||
border-color: transparent transparent var(--popover-opacity-color-2-mildblack) transparent;
|
||||
margin-left: -6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.left > & {
|
||||
border-width: 6px 0 6px 8px;
|
||||
border-color: transparent transparent transparent var(--popover-opacity-color-2-mildblack);
|
||||
margin-top: -6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.right > & {
|
||||
border-width: 6px 8px 6px 0;
|
||||
border-color: transparent var(--popover-opacity-color-2-mildblack) transparent transparent;
|
||||
margin-top: -6px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.popover-container {
|
||||
display: flex;
|
||||
|
||||
.top > & {
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bottom > & {
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.left > & {
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.right > & {
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
background: var(--popover-opacity-color-2-mildblack);
|
||||
box-shadow: var(--color-0-black) 0 0 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--color-e-nearwhite);
|
||||
font-size: inherit;
|
||||
padding: 8px;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export enum PopoverDirection {
|
||||
Top = "Top",
|
||||
Bottom = "Bottom",
|
||||
Left = "Left",
|
||||
Right = "Right",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
direction: { type: String, default: PopoverDirection.Bottom },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
open: false,
|
||||
mouseStillDown: false,
|
||||
PopoverDirection,
|
||||
};
|
||||
},
|
||||
updated() {
|
||||
const popoverContainer = this.$refs.popoverContainer as HTMLElement;
|
||||
const popoverContent = this.$refs.popoverContent as HTMLElement;
|
||||
const workspace = document.querySelector(".workspace-row");
|
||||
|
||||
if (popoverContent && workspace) {
|
||||
const workspaceBounds = workspace.getBoundingClientRect();
|
||||
const popoverBounds = popoverContent.getBoundingClientRect();
|
||||
|
||||
if (this.direction === PopoverDirection.Left || this.direction === PopoverDirection.Right) {
|
||||
const topOffset = popoverBounds.top - workspaceBounds.top - 8;
|
||||
if (topOffset < 0) popoverContainer.style.transform = `translate(0, ${-topOffset}px)`;
|
||||
|
||||
const bottomOffset = workspaceBounds.bottom - popoverBounds.bottom - 8;
|
||||
if (bottomOffset < 0) popoverContainer.style.transform = `translate(0, ${bottomOffset}px)`;
|
||||
}
|
||||
|
||||
if (this.direction === PopoverDirection.Top || this.direction === PopoverDirection.Bottom) {
|
||||
const leftOffset = popoverBounds.left - workspaceBounds.left - 8;
|
||||
if (leftOffset < 0) popoverContainer.style.transform = `translate(${-leftOffset}px, 0)`;
|
||||
|
||||
const rightOffset = workspaceBounds.right - popoverBounds.right - 8;
|
||||
if (rightOffset < 0) popoverContainer.style.transform = `translate(${rightOffset}px, 0)`;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setOpen() {
|
||||
this.open = true;
|
||||
},
|
||||
setClosed() {
|
||||
this.open = false;
|
||||
},
|
||||
mouseMoveHandler(e: MouseEvent) {
|
||||
const MOUSE_STRAY_DISTANCE = 100;
|
||||
|
||||
// Close the popover if the mouse has strayed far enough from its bounds
|
||||
if (this.isMouseEventOutsidePopover(e, MOUSE_STRAY_DISTANCE)) {
|
||||
this.setClosed();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const eventIncludesLmb = Boolean(e.buttons & 1);
|
||||
|
||||
// Clean up any messes from lost mouseup events
|
||||
if (!this.open && !eventIncludesLmb) {
|
||||
this.mouseStillDown = false;
|
||||
window.removeEventListener("mouseup", this.mouseUpHandler);
|
||||
}
|
||||
},
|
||||
mouseDownHandler(e: MouseEvent) {
|
||||
// Close the popover if the mouse clicked outside the popover (but within stray distance)
|
||||
if (this.isMouseEventOutsidePopover(e)) {
|
||||
this.setClosed();
|
||||
|
||||
// Track if the left mouse button is now down so its later click event can be canceled
|
||||
const eventIsForLmb = e.button === 0;
|
||||
if (eventIsForLmb) this.mouseStillDown = true;
|
||||
}
|
||||
},
|
||||
mouseUpHandler(e: MouseEvent) {
|
||||
const eventIsForLmb = e.button === 0;
|
||||
|
||||
if (this.mouseStillDown && eventIsForLmb) {
|
||||
// Clean up self
|
||||
this.mouseStillDown = false;
|
||||
window.removeEventListener("mouseup", this.mouseUpHandler);
|
||||
|
||||
// Prevent the click event from firing, which would normally occur right after this mouseup event
|
||||
window.addEventListener("click", this.clickHandlerCapture, true);
|
||||
}
|
||||
},
|
||||
clickHandlerCapture(e: MouseEvent) {
|
||||
// Stop the click event from reopening this popover if the click event targets the popover's button
|
||||
e.stopPropagation();
|
||||
|
||||
// Clean up self
|
||||
window.removeEventListener("click", this.clickHandlerCapture, true);
|
||||
},
|
||||
isMouseEventOutsidePopover(e: MouseEvent, extraDistanceAllowed = 0): boolean {
|
||||
const popoverContent = this.$refs.popoverContent as HTMLElement;
|
||||
const popoverBounds = popoverContent.getBoundingClientRect();
|
||||
|
||||
if (popoverBounds.left - e.clientX >= extraDistanceAllowed) return true;
|
||||
if (e.clientX - popoverBounds.right >= extraDistanceAllowed) return true;
|
||||
if (popoverBounds.top - e.clientY >= extraDistanceAllowed) return true;
|
||||
if (e.clientY - popoverBounds.bottom >= extraDistanceAllowed) return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
open(newState: boolean, oldState: boolean) {
|
||||
if (newState && !oldState) {
|
||||
// Close popover if mouse strays far enough away
|
||||
window.addEventListener("mousemove", this.mouseMoveHandler);
|
||||
|
||||
// Close popover if mouse is outside (but within stray distance)
|
||||
window.addEventListener("mousedown", this.mouseDownHandler);
|
||||
|
||||
// Cancel the subsequent click event to prevent the popover from reopening if the popover's button is the click event target
|
||||
window.addEventListener("mouseup", this.mouseUpHandler);
|
||||
}
|
||||
if (!newState && oldState) {
|
||||
window.removeEventListener("mousemove", this.mouseMoveHandler);
|
||||
window.removeEventListener("mousedown", this.mouseDownHandler);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user