Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test #1

Merged
merged 1 commit into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ tech changes will usually be stripped from release notes for the public

## Unreleased

### Added

- Notes:
- Notes can now be popped out to a separate window

### Changed

- Dashboard:
Expand Down
109 changes: 109 additions & 0 deletions client/src/core/components/WindowPortal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* This component is adapted from https://stackoverflow.com/questions/49657462/open-a-vuejs-component-on-a-new-window/58534753#58534753
*/

import { nextTick, onMounted, onBeforeUnmount, ref, watch } from "vue";

import { modalState } from "../../game/systems/modals/state";
import type { ModalIndex } from "../../game/systems/modals/types";

const props = defineProps<{ modalIndex?: ModalIndex; visible: boolean }>();
const emit = defineEmits<{ (e: "close"): void }>();

let windowRef: Window | null = null;
let originalParent: HTMLElement | null = null;
const portal = ref<HTMLElement | null>(null);

const copyStyles = (sourceDoc: Document, targetDoc: Document): void => {
for (const styleSheet of Array.from(sourceDoc.styleSheets)) {
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (styleSheet.cssRules) {
// for <style> elements
const nwStyleElement = sourceDoc.createElement("style");

for (const cssRule of Array.from(styleSheet.cssRules)) {
// write the text of each rule into the body of the style element
nwStyleElement.append(sourceDoc.createTextNode(cssRule.cssText));
}

targetDoc.head.append(nwStyleElement);
} else if (styleSheet.href !== null) {
// for <link> elements loading CSS from a URL
const nwLinkElement = sourceDoc.createElement("link");

nwLinkElement.rel = "stylesheet";
nwLinkElement.href = styleSheet.href;
targetDoc.head.append(nwLinkElement);
}
}
};

const openPortal = async (): Promise<void> => {
if (props.modalIndex) {
// When popped out, we no longer care about focus order for this modal,
// so we track this
modalState.mutableReactive.poppedModals.add(props.modalIndex);
}

await nextTick();

const height = portal.value?.children[0]?.clientHeight ?? 0;
const width = portal.value?.children[0]?.clientWidth ?? 0;

// We add 2 times 0.4 * 16px as we use 0.4rem as a small offset (see Modal.vue)
windowRef = window.open("", "", `popup=true,height=${height + 0.8 * 16}px,width=${width + 0.8 * 16}px`);
if (!windowRef || portal.value === null) return;

originalParent = portal.value.parentElement;

// Append the portal to the new window, removing it from the old one
windowRef.document.body.append(portal.value);

// Ensure CSS is up to date
copyStyles(window.document, windowRef.document);

// Close the portal when the parent window closes and when the portal itself is closed
window.addEventListener("beforeunload", closePortal);
windowRef.addEventListener("beforeunload", closePortal);
};

const closePortal = (): void => {
if (props.modalIndex) {
modalState.mutableReactive.poppedModals.delete(props.modalIndex);
}
if (windowRef) {
windowRef.close();
windowRef = null;
emit("close");
}
};

watch(props, async () => {
if (props.visible) {
await openPortal();
} else {
if (originalParent) {
originalParent.append(portal.value!);
}
closePortal();
}
});

onMounted(async () => {
if (props.visible) {
await openPortal();
}
});
onBeforeUnmount(() => {
if (windowRef) {
closePortal();
}
});
</script>

<template>
<div ref="portal">
<slot />
</div>
</template>
89 changes: 69 additions & 20 deletions client/src/core/components/modals/Modal.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch, watchEffect } from "vue";

import WindowPortal from "../../../core/components/WindowPortal.vue";
import type { ModalIndex } from "../../../game/systems/modals/types";
import { clearDropCallback, registerDropCallback } from "../../../game/ui/firefox";

const props = withDefaults(
defineProps<{ colour?: string; mask?: boolean; visible: boolean; rightHanded?: boolean }>(),
defineProps<{
colour?: string;
mask?: boolean;
visible: boolean;
rightHanded?: boolean;
modalIndex?: ModalIndex;
// Extra class to add to the modal container
// This can be useful when a :deep cannot be used due to
// e.g. windowed modals no longer having the correct child chain
extraClass?: string;
}>(),
{
colour: "white",
mask: true,
Expand All @@ -13,9 +25,14 @@ const props = withDefaults(
// important to note however is that native resize behaviour will not work properly with this.
// (see NoteDialog for a left-handed component that remains right aligned when resized)
rightHanded: false,
modalIndex: undefined,
extraClass: "",
},
);
const emit = defineEmits<(e: "close" | "focus") => void>();
const emit = defineEmits<{
(e: "close" | "focus"): void;
(e: "window-toggle", value: boolean): void;
}>();

const container = ref<HTMLDivElement | null>(null);

Expand Down Expand Up @@ -135,6 +152,27 @@ function dragEnd(event: DragEvent): void {
function dragOver(_event: DragEvent): void {
if (dragging && container.value) container.value.style.display = "none";
}

// Windowed mode

const windowed = ref(false);
const preWindowState = { left: "", top: "" };
function toggleWindow(): void {
if (!container.value) return;

windowed.value = !windowed.value;
emit("window-toggle", windowed.value);

if (windowed.value) {
preWindowState.left = container.value.style.left;
preWindowState.top = container.value.style.top;
container.value.style.left = "0.4rem";
container.value.style.top = "0.4rem";
} else {
container.value.style.left = preWindowState.left;
container.value.style.top = preWindowState.top;
}
}
</script>

<template>
Expand All @@ -146,15 +184,23 @@ function dragOver(_event: DragEvent): void {
@click="close"
@dragover.prevent="dragOver"
>
<div
ref="container"
class="modal-container"
:style="{ backgroundColor: colour }"
@click.stop="emit('focus')"
>
<slot name="header" :drag-start="dragStart" :drag-end="dragEnd"></slot>
<slot></slot>
</div>
<WindowPortal :visible="windowed" :modal-index="props.modalIndex">
<div
ref="container"
class="modal-container"
:class="extraClass"
:style="{ backgroundColor: colour }"
@click.stop="emit('focus')"
>
<slot
name="header"
:drag-start="dragStart"
:drag-end="dragEnd"
:toggle-window="toggleWindow"
></slot>
<slot></slot>
</div>
</WindowPortal>
</div>
</transition>
</template>
Expand Down Expand Up @@ -182,15 +228,18 @@ function dragOver(_event: DragEvent): void {
transition: opacity 0.3s ease;
}

.modal-container {
pointer-events: auto;
position: absolute;
width: auto;
height: auto;
border-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande",
sans-serif;
/* Use a layer to ensure components can override these styles when passing an extraClass */
@layer base-modals {
.modal-container {
pointer-events: auto;
position: absolute;
width: auto;
height: auto;
border-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande",
sans-serif;
}
}

.modal-enter {
Expand Down
4 changes: 4 additions & 0 deletions client/src/game/systems/modals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class ModalSystem implements System {
}

focus(index: ModalIndex): void {
// Interactin with popped modals (i.e. diferent windows)
// is funky and it doesn't make much sense anyway, so just skip them
if ($.poppedModals.has(index)) return;

$.openModals.add(index);
if (raw.modalOrder.at(-1) === index) return;
const orderId = raw.modalOrder.findIndex((m) => m === index);
Expand Down
3 changes: 3 additions & 0 deletions client/src/game/systems/modals/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ interface ReactiveModalState {
modalOrder: ModalIndex[];
// Dynamically injected modals
extraModals: IndexedModal[];
// Popped out modals
poppedModals: Set<ModalIndex>;
}

const state = buildState<ReactiveModalState, ModalState>(
{
openModals: new Set(),
modalOrder: [],
extraModals: [],
poppedModals: new Set(),
},
{
fixedModals: [],
Expand Down
41 changes: 36 additions & 5 deletions client/src/game/ui/notes/NoteDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,34 @@ function close(): void {
function setText(event: Event, sync: boolean): void {
noteSystem.setText(props.uuid, (event.target as HTMLTextAreaElement).value, sync, !sync);
}

const isWindowed = ref(false);
const previousWindowState = { width: "", height: "" };
function windowToggle(windowed: boolean): void {
isWindowed.value = windowed;
if (windowed) {
previousWindowState.width = modal.value!.container.style.width;
previousWindowState.height = modal.value!.container.style.height;
modal.value!.container.style.width = "auto";
modal.value!.container.style.height = "auto";
} else {
modal.value!.container.style.width = previousWindowState.width;
modal.value!.container.style.height = previousWindowState.height;
}
}
</script>

<template>
<Modal v-if="note !== undefined" ref="modal" :visible="true" :mask="false" @close="close">
<Modal
v-if="note !== undefined"
ref="modal"
:visible="true"
:mask="false"
:modal-index="props.modalIndex"
extra-class="note-dialog"
@close="close"
@window-toggle="windowToggle"
>
<template #header="m">
<header draggable="true" @dragstart="m.dragStart" @dragend="m.dragEnd">
<div>
Expand All @@ -125,8 +149,13 @@ function setText(event: Event, sync: boolean): void {
:icon="['far', 'square-plus']"
@click="expand"
/>
<font-awesome-icon v-else :icon="['far', 'square-minus']" @click="collapse" />
<font-awesome-icon :icon="['far', 'window-close']" @click="close" />
<font-awesome-icon v-else :icon="['far', 'square-minus']" title="Collapse note" @click="collapse" />
<font-awesome-icon
:icon="['far', 'window-restore']"
:title="`${isWindowed ? 'Restore' : 'Pop out'} note`"
@click="m.toggleWindow"
/>
<font-awesome-icon :icon="['far', 'window-close']" title="Close note" @click="close" />
</div>
<div>
<div v-if="!editing" @click="editing = true">[edit]</div>
Expand All @@ -143,8 +172,8 @@ function setText(event: Event, sync: boolean): void {
</Modal>
</template>

<style scoped lang="scss">
:deep(.modal-container) {
<style lang="scss">
.note-dialog {
display: flex;
flex-direction: column;

Expand All @@ -155,7 +184,9 @@ function setText(event: Event, sync: boolean): void {
min-height: 5rem;
overflow: auto;
}
</style>

<style scoped lang="scss">
header {
position: sticky;
top: 0;
Expand Down