Fix unresponsive copy button on instructions modal

This commit fixes the bug where the "Copy" button does not copy when
clicked on download instructions modal (on Linux and macOS).

This commit also introduces several improvements to the UI components
related to copy action and their interaction with the clipboard feature.

It adds more tests to avoid regression of the bugs and improves
maintainability, testability and adherence to Vue's reactive principles.

Changes include:

- Fix non-responsive copy button in the download instructions modal by
  triggering a `click` event in `AppIcon.vue`.
- Improve `TheCodeButtons.vue`:
  - Remove redundant `getCurrentCode` function.
  - Separate components for each button for better separation of
    concerns and higher testability.
  - Use the `gap` property in the flexbox layout, replacing the less
    explicit sibling combinator approach.
- Add `useClipboard` compositional hook for more idiomatic Vue approach
  to interacting with the clipboard.
- Add `useCurrentCode` compositional hook to handle current code state
  more effectively with unified logic.
- Abstract clipboard operations to an interface to isolate
  responsibilities.
- Switch clipboard implementation to the `navigator.clipboard` API,
  moving away from the deprecated `document.execCommand`.
- Move clipboard logic to the presentation layer to conform to
  separation of concerns and domain-driven design principles.
- Improve `IconButton.vue` component to increase reusability with
  consistent sizing.
This commit is contained in:
undergroundwires
2023-11-06 21:55:43 +01:00
parent b2ffc90da7
commit 8ccaec7af6
37 changed files with 881 additions and 206 deletions

View File

@@ -0,0 +1,33 @@
<template>
<IconButton
text="Copy"
@click="copyCode"
icon-name="copy"
/>
</template>
<script lang="ts">
import {
defineComponent, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import IconButton from './IconButton.vue';
export default defineComponent({
components: {
IconButton,
},
setup() {
const { copyText } = inject(InjectionKeys.useClipboard)();
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
async function copyCode() {
await copyText(currentCode.value);
}
return {
copyCode,
};
},
});
</script>

View File

@@ -0,0 +1,59 @@
<template>
<IconButton
v-if="canRun"
text="Run"
@click="executeCode"
icon-name="play"
/>
</template>
<script lang="ts">
import {
defineComponent, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import IconButton from './IconButton.vue';
export default defineComponent({
components: {
IconButton,
},
setup() {
const { currentState, currentContext } = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
async function executeCode() {
await runCode(currentContext);
}
return {
isDesktopVersion: isDesktop,
canRun,
executeCode,
};
},
});
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
hostOs: OperatingSystem,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
}
async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner();
await runner.runCode(
/* code: */ context.state.code.current,
/* appName: */ context.app.info.name,
/* fileExtension: */ context.state.collection.scripting.fileExtension,
);
}
</script>

View File

@@ -1,15 +1,17 @@
<template>
<button
class="button"
type="button"
@click="onClicked"
>
<AppIcon
class="button__icon"
:icon="iconName"
/>
<div class="button__text">{{text}}</div>
</button>
<div class="button-wrapper">
<button
class="button"
type="button"
@click="onClicked"
>
<AppIcon
class="button__icon"
:icon="iconName"
/>
<div class="button__text">{{text}}</div>
</button>
</div>
</template>
<script lang="ts">
@@ -49,10 +51,20 @@ export default defineComponent({
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.button-wrapper {
position: relative;
height: 70px;
.button {
position: absolute;
width: 100%;
height: 100%;
}
}
.button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: $color-secondary;
color: $color-on-secondary;
@@ -70,19 +82,17 @@ export default defineComponent({
@include clickable;
width: 10%;
min-width: 90px;
@include hover-or-touch {
background: $color-surface;
box-shadow: 0px 2px 10px 5px $color-secondary;
.button__text {
display: block;
}
.button__icon {
display: none;
}
}
@include hover-or-touch('>&__text') {
display: block;
}
@include hover-or-touch('>&__icon') {
display: none;
}
&__text {
.button__text {
display: none;
font-family: $font-artistic;
font-size: 1.5em;

View File

@@ -0,0 +1,95 @@
<template>
<div>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
@click="saveCode"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
<InstructionList :data="instructions" />
</ModalDialog>
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
import IconButton from '../IconButton.vue';
import InstructionList from './Instructions/InstructionList.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
export default defineComponent({
components: {
IconButton,
InstructionList,
ModalDialog,
},
setup() {
const { currentState } = inject(InjectionKeys.useCollectionState)();
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const areInstructionsVisible = ref(false);
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
currentState.value.collection.os,
fileName.value,
));
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
areInstructionsVisible.value = true;
}
return {
isDesktopVersion: isDesktop,
instructions,
fileName,
areInstructionsVisible,
saveCode,
};
},
});
function getDownloadInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData | undefined {
if (!hasInstructions(os)) {
return undefined;
}
return getInstructions(os, fileName);
}
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
case ScriptingLanguage.shellscript:
return FileType.ShellScript;
default:
throw new Error('unknown file type');
}
}
function buildFileName(scripting: IScriptingDefinition) {
const fileName = 'privacy-script';
if (scripting.fileExtension) {
return `${fileName}.${scripting.fileExtension}`;
}
return fileName;
}
</script>

View File

@@ -16,10 +16,10 @@
</template>
<script lang="ts">
import { defineComponent, shallowRef } from 'vue';
import { Clipboard } from '@/infrastructure/Clipboard';
import { defineComponent, shallowRef, inject } from 'vue';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
export default defineComponent({
components: {
@@ -27,9 +27,11 @@ export default defineComponent({
AppIcon,
},
setup() {
const { copyText } = inject(InjectionKeys.useClipboard)();
const codeElement = shallowRef<HTMLElement | undefined>();
function copyCode() {
async function copyCode() {
const element = codeElement.value;
if (!element) {
throw new Error('Code element could not be found.');
@@ -38,7 +40,7 @@ export default defineComponent({
if (!code) {
throw new Error('Code element does not contain any text.');
}
Clipboard.copyText(code);
await copyText(code);
}
return {

View File

@@ -1,168 +1,36 @@
<template>
<div class="container" v-if="hasCode">
<IconButton
v-if="canRun"
text="Run"
v-on:click="executeCode"
icon-name="play"
/>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
v-on:click="saveCode"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
/>
<IconButton
text="Copy"
v-on:click="copyCode"
icon-name="copy"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
<InstructionList :data="instructions" />
</ModalDialog>
<CodeRunButton class="code-button" />
<CodeSaveButton class="code-button" />
<CodeCopyButton class="code-button" />
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, computed, inject,
defineComponent, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import InstructionList from './Instructions/InstructionList.vue';
import IconButton from './IconButton.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
import CodeRunButton from './CodeRunButton.vue';
import CodeCopyButton from './CodeCopyButton.vue';
import CodeSaveButton from './Save/CodeSaveButton.vue';
export default defineComponent({
components: {
IconButton,
InstructionList,
ModalDialog,
CodeRunButton,
CodeCopyButton,
CodeSaveButton,
},
setup() {
const {
currentState, currentContext, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
const areInstructionsVisible = ref(false);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const hasCode = ref(false);
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
currentState.value.collection.os,
fileName.value,
));
async function copyCode() {
const code = await getCurrentCode();
Clipboard.copyText(code.current);
}
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
areInstructionsVisible.value = true;
}
async function executeCode() {
await runCode(currentContext);
}
onStateChange((newState) => {
updateCurrentCode(newState.code.current);
subscribeToCodeChanges(newState.code);
}, { immediate: true });
function subscribeToCodeChanges(code: IApplicationCode) {
events.unsubscribeAllAndRegister([
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
]);
}
function updateCurrentCode(code: string) {
hasCode.value = code && code.length > 0;
}
async function getCurrentCode(): Promise<IApplicationCode> {
const { code } = currentContext.state;
return code;
}
const hasCode = computed<boolean>(() => currentCode.value.length > 0);
return {
isDesktopVersion: isDesktop,
canRun,
hasCode,
instructions,
fileName,
areInstructionsVisible,
copyCode,
saveCode,
executeCode,
};
},
});
function getDownloadInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData | undefined {
if (!hasInstructions(os)) {
return undefined;
}
return getInstructions(os, fileName);
}
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
hostOs: OperatingSystem,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
}
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
case ScriptingLanguage.shellscript:
return FileType.ShellScript;
default:
throw new Error('unknown file type');
}
}
function buildFileName(scripting: IScriptingDefinition) {
const fileName = 'privacy-script';
if (scripting.fileExtension) {
return `${fileName}.${scripting.fileExtension}`;
}
return fileName;
}
async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner();
await runner.runCode(
/* code: */ context.state.code.current,
/* appName: */ context.app.info.name,
/* fileExtension: */ context.state.collection.scripting.fileExtension,
);
}
</script>
<style scoped lang="scss">
@@ -170,8 +38,10 @@ async function runCode(context: IReadOnlyApplicationContext) {
display: flex;
flex-direction: row;
justify-content: center;
gap: 30px;
}
.container > * + * {
margin-left: 30px;
.code-button {
width: 10%;
min-width: 90px;
}
</style>