mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 06:22:44 -08:00
initial commit
This commit is contained in:
747
vendor/texthooker-ui/src/components/App.svelte
vendored
Normal file
747
vendor/texthooker-ui/src/components/App.svelte
vendored
Normal file
@@ -0,0 +1,747 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
mdiArrowULeftTop,
|
||||
mdiCancel,
|
||||
mdiCog,
|
||||
mdiDelete,
|
||||
mdiDeleteForever,
|
||||
mdiNoteEdit,
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
mdiWindowMaximize,
|
||||
mdiWindowRestore,
|
||||
} from '@mdi/js';
|
||||
import { debounceTime, filter, fromEvent, map, NEVER, switchMap, tap } from 'rxjs';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { quintInOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import {
|
||||
actionHistory$,
|
||||
allowNewLineDuringPause$,
|
||||
allowPasteDuringPause$,
|
||||
autoStartTimerDuringPause$,
|
||||
autoStartTimerDuringPausePaste$,
|
||||
blockCopyOnPage$,
|
||||
customCSS$,
|
||||
dialogOpen$,
|
||||
displayVertical$,
|
||||
enabledReplacements$,
|
||||
enablePaste$,
|
||||
filterNonCJKLines$,
|
||||
flashOnMissedLine$,
|
||||
flashOnPauseTimeout$,
|
||||
fontSize$,
|
||||
isPaused$,
|
||||
lastPipHeight$,
|
||||
lastPipWidth$,
|
||||
lineData$,
|
||||
maxLines$,
|
||||
maxPipLines$,
|
||||
mergeEqualLineStarts$,
|
||||
newLine$,
|
||||
notesOpen$,
|
||||
onlineFont$,
|
||||
openDialog$,
|
||||
preventGlobalDuplicate$,
|
||||
preventLastDuplicate$,
|
||||
removeAllWhitespace$,
|
||||
replacements$,
|
||||
reverseLineOrder$,
|
||||
secondaryWebsocketUrl$,
|
||||
showConnectionIcon$,
|
||||
showSpinner$,
|
||||
theme$,
|
||||
websocketUrl$,
|
||||
} from '../stores/stores';
|
||||
import { LineType, OnlineFont, Theme, type LineItem, type LineItemEditEvent } from '../types';
|
||||
import {
|
||||
applyAfkBlur,
|
||||
applyCustomCSS,
|
||||
applyReplacements,
|
||||
generateRandomUUID,
|
||||
newLineCharacter,
|
||||
reduceToEmptyString,
|
||||
updateScroll,
|
||||
} from '../util';
|
||||
import DialogManager from './DialogManager.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import Line from './Line.svelte';
|
||||
import Notes from './Notes.svelte';
|
||||
import Presets from './Presets.svelte';
|
||||
import Settings from './Settings.svelte';
|
||||
import SocketConnector from './SocketConnector.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import Stats from './Stats.svelte';
|
||||
|
||||
let isSmFactor = false;
|
||||
let settingsComponent: Settings;
|
||||
let selectedLineIds: string[] = [];
|
||||
let settingsContainer: HTMLElement;
|
||||
let settingsElement: SVGElement;
|
||||
let settingsOpen = false;
|
||||
let lineContainer: HTMLElement;
|
||||
let lineElements: Line[] = [];
|
||||
let lineInEdit = false;
|
||||
let blockNextExternalLine = false;
|
||||
let wakeLock = null;
|
||||
let pipContainer: HTMLElement;
|
||||
let pipWindow: Window | undefined;
|
||||
let pipResizeTimeout: number;
|
||||
let hasPipFocus = false;
|
||||
|
||||
const wakeLockAvailable = 'wakeLock' in navigator;
|
||||
|
||||
const cjkCharacters = /[\p{scx=Hira}\p{scx=Kana}\p{scx=Han}]/imu;
|
||||
|
||||
const uniqueLines$ = preventGlobalDuplicate$.pipe(
|
||||
map((preventGlobalDuplicate) =>
|
||||
preventGlobalDuplicate ? new Set<string>($lineData$.map((line) => line.text)) : new Set<string>(),
|
||||
),
|
||||
);
|
||||
|
||||
const handleLine$ = newLine$.pipe(
|
||||
filter(([_, lineType]) => {
|
||||
const isPaste = lineType === LineType.PASTE;
|
||||
const hasNoUserInteraction = !isPaste || (!$notesOpen$ && !$dialogOpen$ && !settingsOpen && !lineInEdit);
|
||||
const skipExternalLine = blockNextExternalLine && lineType === LineType.EXTERNAL;
|
||||
|
||||
if (skipExternalLine) {
|
||||
blockNextExternalLine = false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!$isPaused$ ||
|
||||
(($allowPasteDuringPause$ || $autoStartTimerDuringPausePaste$) && isPaste) ||
|
||||
(($allowNewLineDuringPause$ || $autoStartTimerDuringPause$) && !isPaste)) &&
|
||||
hasNoUserInteraction &&
|
||||
!skipExternalLine
|
||||
) {
|
||||
if (
|
||||
$isPaused$ &&
|
||||
(($autoStartTimerDuringPausePaste$ && isPaste) || ($autoStartTimerDuringPause$ && !isPaste))
|
||||
) {
|
||||
$isPaused$ = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!skipExternalLine && hasNoUserInteraction && $flashOnMissedLine$) {
|
||||
handleMissedLine();
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
tap((newLine: [string, LineType]) => {
|
||||
const [lineContent] = newLine;
|
||||
const text = transformLine(lineContent);
|
||||
|
||||
if (text) {
|
||||
$lineData$ = applyEqualLineStartMerge([
|
||||
...applyMaxLinesAndGetRemainingLineData(1),
|
||||
{ id: generateRandomUUID(), text },
|
||||
]);
|
||||
}
|
||||
}),
|
||||
reduceToEmptyString(),
|
||||
);
|
||||
|
||||
const pasteHandler$ = enablePaste$.pipe(
|
||||
switchMap((enablePaste) => (enablePaste ? fromEvent(document, 'paste') : NEVER)),
|
||||
tap((event: ClipboardEvent) => newLine$.next([event.clipboardData.getData('text/plain'), LineType.PASTE])),
|
||||
reduceToEmptyString(),
|
||||
);
|
||||
|
||||
const visibilityHandler$ = fromEvent(document, 'visibilitychange').pipe(
|
||||
tap(() => {
|
||||
if (wakeLockAvailable && wakeLock !== null && document.visibilityState === 'visible') {
|
||||
wakeLock = navigator.wakeLock
|
||||
.request('screen')
|
||||
.then((lock) => {
|
||||
return lock;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Unable to aquire screen lock: ${error.message}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}),
|
||||
reduceToEmptyString(),
|
||||
);
|
||||
|
||||
const copyBlocker$ = blockCopyOnPage$.pipe(
|
||||
switchMap((blockCopyOnPage) => {
|
||||
blockNextExternalLine = false;
|
||||
|
||||
return blockCopyOnPage ? fromEvent(document, 'copy') : NEVER;
|
||||
}),
|
||||
tap(() => (blockNextExternalLine = true)),
|
||||
reduceToEmptyString(),
|
||||
);
|
||||
|
||||
const resizeHandler$ = fromEvent(window, 'resize').pipe(
|
||||
debounceTime(500),
|
||||
tap(mountFunction),
|
||||
reduceToEmptyString(),
|
||||
);
|
||||
|
||||
$: iconSize = isSmFactor ? '1.5rem' : '1.25rem';
|
||||
|
||||
$: $enabledReplacements$ = $replacements$.filter((replacment) => replacment.enabled);
|
||||
|
||||
$: pipAvailable = 'documentPictureInPicture' in window && !!pipContainer;
|
||||
|
||||
$: pipLines = pipAvailable && $lineData$ ? $lineData$.slice(-$maxPipLines$) : [];
|
||||
|
||||
$: if (pipWindow) {
|
||||
pipWindow.document.body.dataset.theme = $theme$;
|
||||
|
||||
applyCustomCSS(pipWindow.document, $customCSS$);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mountFunction();
|
||||
if (wakeLockAvailable) {
|
||||
wakeLock = navigator.wakeLock
|
||||
.request('screen')
|
||||
.then((lock) => {
|
||||
return lock;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Unable to aquire screen lock: ${error.message}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function mountFunction() {
|
||||
isSmFactor = window.matchMedia('(min-width: 640px)').matches;
|
||||
executeUpdateScroll();
|
||||
}
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if ($notesOpen$ || $dialogOpen$ || settingsOpen || lineInEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = (event.key || '')?.toLowerCase();
|
||||
|
||||
if (key === 'delete') {
|
||||
if (window.getSelection()?.toString().trim()) {
|
||||
const range = window.getSelection().getRangeAt(0);
|
||||
|
||||
for (let index = 0, { length } = lineElements; index < length; index += 1) {
|
||||
const lineElement = lineElements[index];
|
||||
const selectedId = lineElement?.getIdIfSelected(range);
|
||||
|
||||
if (selectedId) {
|
||||
selectedLineIds.push(selectedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLineIds.length) {
|
||||
removeLines();
|
||||
} else if (event.altKey) {
|
||||
removeLastLine();
|
||||
}
|
||||
} else if (selectedLineIds.length && key === 'escape') {
|
||||
deselectLines();
|
||||
} else if (event.altKey && key === 'a') {
|
||||
settingsComponent.handleReset(false);
|
||||
} else if (event.altKey && key === 'q') {
|
||||
settingsComponent.handleReset(true);
|
||||
} else if ((event.ctrlKey || event.metaKey) && key === ' ') {
|
||||
$isPaused$ = !$isPaused$;
|
||||
} else if (event.altKey && key === 'g') {
|
||||
$showConnectionIcon$ = !$showConnectionIcon$;
|
||||
}
|
||||
}
|
||||
|
||||
async function undoLastAction() {
|
||||
if (!$actionHistory$.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const linesToRevert = $actionHistory$.pop();
|
||||
|
||||
let lineToRevert = linesToRevert.pop();
|
||||
|
||||
while (lineToRevert) {
|
||||
const text = transformLine(lineToRevert.text, false);
|
||||
|
||||
if (text) {
|
||||
const { id, index } = lineToRevert;
|
||||
|
||||
if (index > $lineData$.length - 1) {
|
||||
$lineData$.push({ id, text });
|
||||
} else if ($lineData$[index].id === id) {
|
||||
$lineData$[index] = { id, text };
|
||||
} else {
|
||||
$lineData$.splice(index, 0, { id, text });
|
||||
}
|
||||
}
|
||||
|
||||
lineToRevert = linesToRevert.pop();
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
$lineData$ = applyEqualLineStartMerge(applyMaxLinesAndGetRemainingLineData());
|
||||
$actionHistory$ = $actionHistory$;
|
||||
}
|
||||
|
||||
function removeLastLine() {
|
||||
if (!$lineData$.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [removedLine] = $lineData$.splice($lineData$.length - 1, 1);
|
||||
|
||||
selectedLineIds = selectedLineIds.filter((selectedLineId) => selectedLineId !== removedLine.id);
|
||||
$lineData$ = $lineData$;
|
||||
$actionHistory$ = [...$actionHistory$, [{ ...removedLine, index: $lineData$.length }]];
|
||||
|
||||
$uniqueLines$.delete(removedLine.text);
|
||||
}
|
||||
|
||||
function removeLines() {
|
||||
const linesToDelete = new Set(selectedLineIds);
|
||||
const newActionHistory: LineItem[] = [];
|
||||
|
||||
$lineData$ = $lineData$.filter((oldLine, index) => {
|
||||
const hasLine = linesToDelete.has(oldLine.id);
|
||||
|
||||
linesToDelete.delete(oldLine.id);
|
||||
|
||||
if (hasLine) {
|
||||
newActionHistory.push({ ...oldLine, index: index - newActionHistory.length });
|
||||
$uniqueLines$.delete(oldLine.text);
|
||||
}
|
||||
|
||||
return !hasLine;
|
||||
});
|
||||
|
||||
selectedLineIds = linesToDelete.size ? [...linesToDelete] : [];
|
||||
|
||||
if (newActionHistory.length) {
|
||||
$actionHistory$ = [...$actionHistory$, newActionHistory];
|
||||
}
|
||||
}
|
||||
|
||||
function deselectLines() {
|
||||
for (let index = 0, { length } = lineElements; index < length; index += 1) {
|
||||
lineElements[index]?.deselect();
|
||||
}
|
||||
|
||||
selectedLineIds = [];
|
||||
}
|
||||
|
||||
async function handlePipAction() {
|
||||
if (pipWindow) {
|
||||
return pipWindow.close();
|
||||
}
|
||||
|
||||
pipWindow = await window.documentPictureInPicture
|
||||
.requestWindow(
|
||||
$lastPipHeight$ > 0 && $lastPipWidth$ > 0
|
||||
? { height: $lastPipHeight$, width: $lastPipWidth$, preferInitialWindowPlacement: false }
|
||||
: { preferInitialWindowPlacement: false },
|
||||
)
|
||||
.catch(({ message }) => {
|
||||
$openDialog$ = {
|
||||
message: `Error opening floating window: ${message}`,
|
||||
showCancel: false,
|
||||
};
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (!pipWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
pipWindow.document.body.appendChild(pipContainer);
|
||||
|
||||
pipWindow.addEventListener('pagehide', onPipHide, { once: true });
|
||||
pipWindow.addEventListener('resize', onPipResize, false);
|
||||
pipWindow.addEventListener('blur', onPipFocusBlur, false);
|
||||
pipWindow.addEventListener('focus', onPipFocusBlur, false);
|
||||
|
||||
[...document.styleSheets].forEach((styleSheet) => {
|
||||
if (styleSheet.ownerNode instanceof Element && styleSheet.ownerNode.id === 'user-css') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
|
||||
const style = document.createElement('style');
|
||||
|
||||
style.textContent = cssRules;
|
||||
pipWindow.document.head.appendChild(style);
|
||||
} catch (_error) {
|
||||
const link = document.createElement('link');
|
||||
|
||||
link.rel = 'stylesheet';
|
||||
link.type = styleSheet.type;
|
||||
link.media = styleSheet.media.toString();
|
||||
link.href = styleSheet.href;
|
||||
pipWindow.document.head.appendChild(link);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onPipHide() {
|
||||
updatePipDimensions();
|
||||
|
||||
pipWindow.removeEventListener('resize', onPipResize, false);
|
||||
pipWindow.removeEventListener('blur', onPipFocusBlur, false);
|
||||
pipWindow.removeEventListener('focus', onPipFocusBlur, false);
|
||||
|
||||
hasPipFocus = false;
|
||||
pipWindow = undefined;
|
||||
}
|
||||
|
||||
function onPipResize() {
|
||||
window.clearTimeout(pipResizeTimeout);
|
||||
|
||||
pipResizeTimeout = window.setTimeout(updatePipDimensions, 500);
|
||||
}
|
||||
|
||||
function updatePipDimensions() {
|
||||
if (!pipWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lastPipHeight$ = pipWindow.document.body.clientHeight;
|
||||
$lastPipWidth$ = pipWindow.document.body.clientWidth;
|
||||
}
|
||||
|
||||
function onPipFocusBlur(event: Event) {
|
||||
hasPipFocus = event.type === 'focus';
|
||||
}
|
||||
|
||||
function onAfkBlur({ detail: isAfk }: CustomEvent<boolean>) {
|
||||
applyAfkBlur(document, isAfk);
|
||||
|
||||
if (pipWindow) {
|
||||
applyAfkBlur(pipWindow.document, isAfk);
|
||||
}
|
||||
}
|
||||
|
||||
function executeUpdateScroll() {
|
||||
updateScroll(window, lineContainer, $reverseLineOrder$, $displayVertical$);
|
||||
|
||||
if (pipWindow) {
|
||||
updateScroll(pipWindow, pipContainer, $reverseLineOrder$, false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMissedLine() {
|
||||
clearTimeout($flashOnPauseTimeout$);
|
||||
|
||||
if ($theme$ === Theme.GARDEN) {
|
||||
settingsContainer.classList.add('bg-base-200');
|
||||
settingsContainer.classList.remove('bg-base-100');
|
||||
document.body.classList.add('bg-base-200');
|
||||
}
|
||||
|
||||
document.body.classList.add('animate-[pulse_0.5s_cubic-bezier(0.4,0,0.6,1)_1]');
|
||||
|
||||
$flashOnPauseTimeout$ = window.setTimeout(() => {
|
||||
if ($theme$ === Theme.GARDEN) {
|
||||
settingsContainer.classList.add('bg-base-100');
|
||||
settingsContainer.classList.remove('bg-base-200');
|
||||
document.body.classList.remove('bg-base-200');
|
||||
}
|
||||
|
||||
document.body.classList.remove('animate-[pulse_0.5s_cubic-bezier(0.4,0,0.6,1)_1]');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function transformLine(text: string, useReplacements = true) {
|
||||
const textToAppend = useReplacements ? applyReplacements(text, $enabledReplacements$) : text;
|
||||
|
||||
let canAppend = true;
|
||||
let lineToAppend = $removeAllWhitespace$ ? textToAppend.replace(/\s/gm, '').trim() : textToAppend;
|
||||
|
||||
if ($filterNonCJKLines$ && !lineToAppend.match(cjkCharacters)) {
|
||||
lineToAppend = '';
|
||||
}
|
||||
|
||||
if (!lineToAppend) {
|
||||
canAppend = false;
|
||||
} else if ($preventGlobalDuplicate$) {
|
||||
canAppend = !$uniqueLines$.has(lineToAppend);
|
||||
$uniqueLines$.add(lineToAppend);
|
||||
} else if ($preventLastDuplicate$ && $lineData$.length) {
|
||||
canAppend = $lineData$.slice(-$preventLastDuplicate$).every((line) => line.text !== lineToAppend);
|
||||
}
|
||||
|
||||
return canAppend ? lineToAppend : undefined;
|
||||
}
|
||||
|
||||
function handleLineEdit(event) {
|
||||
const { inEdit, data } = event.detail as LineItemEditEvent;
|
||||
|
||||
if (data && data.originalText !== data.newText) {
|
||||
const text = transformLine(data.newText);
|
||||
|
||||
$lineData$[data.lineIndex] = {
|
||||
id: data.line.id,
|
||||
text,
|
||||
};
|
||||
|
||||
if (text) {
|
||||
$actionHistory$ = [...$actionHistory$, [{ ...data.line, index: data.lineIndex }]];
|
||||
$uniqueLines$.delete(data.originalText);
|
||||
$uniqueLines$.add(text);
|
||||
} else {
|
||||
tick().then(
|
||||
() =>
|
||||
($lineData$[data.lineIndex] = {
|
||||
id: data.line.id,
|
||||
text: data.originalText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lineInEdit = inEdit;
|
||||
}
|
||||
|
||||
function applyMaxLinesAndGetRemainingLineData(diffMod = 0) {
|
||||
const oldLinesToRemove = new Set<string>();
|
||||
const startIndex = $maxLines$ ? $lineData$.length - $maxLines$ + diffMod : 0;
|
||||
const remainingLineData =
|
||||
startIndex > 0
|
||||
? $lineData$.filter((oldLine, index) => {
|
||||
if (index < startIndex) {
|
||||
oldLinesToRemove.add(oldLine.id);
|
||||
|
||||
$uniqueLines$.delete(oldLine.text);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
: $lineData$;
|
||||
|
||||
if (oldLinesToRemove.size) {
|
||||
selectedLineIds = selectedLineIds.filter((selectedLineId) => !oldLinesToRemove.has(selectedLineId));
|
||||
}
|
||||
|
||||
return remainingLineData;
|
||||
}
|
||||
|
||||
function updateLineData(executeUpdate: boolean) {
|
||||
if (!executeUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
$showSpinner$ = true;
|
||||
|
||||
try {
|
||||
for (let index = 0, { length } = $lineData$; index < length; index += 1) {
|
||||
const line = $lineData$[index];
|
||||
const newText = transformLine(line.text);
|
||||
|
||||
if (newText && newText !== line.text) {
|
||||
$uniqueLines$.delete(line.text);
|
||||
|
||||
$lineData$[index] = { ...line, text: newText };
|
||||
}
|
||||
}
|
||||
|
||||
$openDialog$ = {
|
||||
message: `Operation executed`,
|
||||
showCancel: false,
|
||||
};
|
||||
} catch ({ message }) {
|
||||
$openDialog$ = {
|
||||
type: 'error',
|
||||
message: `An Error occured: ${message}`,
|
||||
showCancel: false,
|
||||
};
|
||||
}
|
||||
|
||||
$lineData$ = applyEqualLineStartMerge(applyMaxLinesAndGetRemainingLineData());
|
||||
|
||||
$showSpinner$ = false;
|
||||
}
|
||||
|
||||
function applyEqualLineStartMerge(currentLineData: LineItem[]) {
|
||||
if (!$mergeEqualLineStarts$ || currentLineData.length < 2) {
|
||||
return currentLineData;
|
||||
}
|
||||
|
||||
const lastIndex = currentLineData.length - 1;
|
||||
const comparisonIndex = lastIndex - 1;
|
||||
const lastLine = currentLineData[lastIndex];
|
||||
const comparisonLine = currentLineData[comparisonIndex].text;
|
||||
|
||||
if (lastLine.text.startsWith(comparisonLine)) {
|
||||
$uniqueLines$.delete(comparisonLine);
|
||||
|
||||
selectedLineIds = selectedLineIds.filter(
|
||||
(selectedLineId) => selectedLineId !== currentLineData[comparisonIndex].id,
|
||||
);
|
||||
|
||||
currentLineData.splice(comparisonIndex, 2, lastLine);
|
||||
}
|
||||
|
||||
return currentLineData;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keyup={handleKeyPress} />
|
||||
|
||||
{$visibilityHandler$ ?? ''}
|
||||
{$handleLine$ ?? ''}
|
||||
{$pasteHandler$ ?? ''}
|
||||
{$copyBlocker$ ?? ''}
|
||||
{$resizeHandler$ ?? ''}
|
||||
|
||||
{#if $showSpinner$}
|
||||
<Spinner />
|
||||
{/if}
|
||||
|
||||
<DialogManager />
|
||||
|
||||
<header class="fixed top-0 right-0 flex justify-end items-center p-2 bg-base-100" bind:this={settingsContainer}>
|
||||
<Stats on:afkBlur={onAfkBlur} />
|
||||
{#if $websocketUrl$}
|
||||
<SocketConnector />
|
||||
{/if}
|
||||
{#if $secondaryWebsocketUrl$}
|
||||
<SocketConnector isPrimary={false} />
|
||||
{/if}
|
||||
{#if $isPaused$}
|
||||
<div
|
||||
role="button"
|
||||
title="Continue"
|
||||
class="mr-1 animate-[pulse_1.25s_cubic-bezier(0.4,0,0.6,1)_infinite] hover:text-primary sm:mr-2"
|
||||
>
|
||||
<Icon path={mdiPlay} width={iconSize} height={iconSize} on:click={() => ($isPaused$ = false)} />
|
||||
</div>
|
||||
{:else}
|
||||
<div role="button" title="Pause" class="mr-1 hover:text-primary sm:mr-2">
|
||||
<Icon path={mdiPause} width={iconSize} height={iconSize} on:click={() => ($isPaused$ = true)} />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
role="button"
|
||||
title="Delete last Line"
|
||||
class="mr-1 hover:text-primary sm:mr-2"
|
||||
class:opacity-50={!$lineData$.length}
|
||||
class:cursor-not-allowed={!$lineData$.length}
|
||||
class:hover:text-primary={$lineData$.length}
|
||||
>
|
||||
<Icon path={mdiDeleteForever} width={iconSize} height={iconSize} on:click={removeLastLine} />
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
title="Undo last Action"
|
||||
class="mr-1 hover:text-primary sm:mr-2"
|
||||
class:opacity-50={!$actionHistory$.length}
|
||||
class:cursor-not-allowed={!$actionHistory$.length}
|
||||
class:hover:text-primary={$actionHistory$.length}
|
||||
>
|
||||
<Icon path={mdiArrowULeftTop} width={iconSize} height={iconSize} on:click={undoLastAction} />
|
||||
</div>
|
||||
{#if selectedLineIds.length}
|
||||
<div role="button" title="Remove selected Lines" class="mr-1 hover:text-primary sm:mr-2">
|
||||
<Icon path={mdiDelete} width={iconSize} height={iconSize} on:click={removeLines} />
|
||||
</div>
|
||||
<div role="button" title="Deselect Lines" class="mr-1 hover:text-primary sm:mr-2">
|
||||
<Icon path={mdiCancel} width={iconSize} height={iconSize} on:click={deselectLines} />
|
||||
</div>
|
||||
{/if}
|
||||
<div role="button" title="Open Notes" class="mr-1 hover:text-primary sm:mr-2">
|
||||
<Icon path={mdiNoteEdit} width={iconSize} height={iconSize} on:click={() => ($notesOpen$ = true)} />
|
||||
</div>
|
||||
{#if pipAvailable}
|
||||
<div
|
||||
role="button"
|
||||
class="mr-1 hover:text-primary sm:mr-2"
|
||||
title={pipWindow ? 'Close Floating Window' : 'Open Floating Window'}
|
||||
>
|
||||
<Icon
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
path={pipWindow ? mdiWindowMaximize : mdiWindowRestore}
|
||||
on:click={handlePipAction}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<Icon
|
||||
class="cursor-pointer mr-1 hover:text-primary md:mr-2"
|
||||
path={mdiCog}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
bind:element={settingsElement}
|
||||
on:click={() => (settingsOpen = !settingsOpen)}
|
||||
/>
|
||||
<Settings
|
||||
{settingsElement}
|
||||
{pipAvailable}
|
||||
bind:settingsOpen
|
||||
bind:selectedLineIds
|
||||
bind:this={settingsComponent}
|
||||
on:applyReplacements={() => updateLineData(!!$enabledReplacements$.length)}
|
||||
on:layoutChange={executeUpdateScroll}
|
||||
on:maxLinesChange={() => ($lineData$ = applyMaxLinesAndGetRemainingLineData())}
|
||||
/>
|
||||
<Presets isQuickSwitch={true} on:layoutChange={executeUpdateScroll} />
|
||||
</header>
|
||||
<main
|
||||
class="flex flex-col flex-1 break-all px-4 w-full h-full overflow-auto"
|
||||
class:py-16={!$displayVertical$}
|
||||
class:py-8={$displayVertical$}
|
||||
class:opacity-50={$notesOpen$}
|
||||
class:flex-col-reverse={$reverseLineOrder$}
|
||||
style:font-size={`${$fontSize$}px`}
|
||||
style:font-family={$onlineFont$ !== OnlineFont.OFF ? $onlineFont$ : undefined}
|
||||
style:writing-mode={$displayVertical$ ? 'vertical-rl' : 'horizontal-tb'}
|
||||
bind:this={lineContainer}
|
||||
>
|
||||
{@html newLineCharacter}
|
||||
{#each $lineData$ as line, index (line.id)}
|
||||
<Line
|
||||
{line}
|
||||
{index}
|
||||
isLast={$lineData$.length - 1 === index}
|
||||
bind:this={lineElements[index]}
|
||||
on:selected={({ detail }) => {
|
||||
selectedLineIds = [...selectedLineIds, detail];
|
||||
}}
|
||||
on:deselected={({ detail }) => {
|
||||
selectedLineIds = selectedLineIds.filter((selectedLineId) => selectedLineId !== detail);
|
||||
}}
|
||||
on:edit={handleLineEdit}
|
||||
/>
|
||||
{/each}
|
||||
</main>
|
||||
{#if $notesOpen$}
|
||||
<div
|
||||
class="bg-base-200 fixed top-0 right-0 z-[60] flex h-full w-full max-w-3xl flex-col justify-between"
|
||||
in:fly|local={{ x: 100, duration: 100, easing: quintInOut }}
|
||||
>
|
||||
<Notes />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id="pip-container"
|
||||
class="flex flex-col flex-1 flex flex-col break-all px-4 w-full h-full overflow-auto"
|
||||
class:flex-col-reverse={$reverseLineOrder$}
|
||||
class:hidden={!pipWindow}
|
||||
style:font-size={`${$fontSize$}px`}
|
||||
style:font-family={$onlineFont$ !== OnlineFont.OFF ? $onlineFont$ : undefined}
|
||||
bind:this={pipContainer}
|
||||
>
|
||||
{#if pipWindow}
|
||||
{#each pipLines as line, index (line.id)}
|
||||
<Line {line} {index} {pipWindow} isLast={pipLines.length - 1 === index} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
64
vendor/texthooker-ui/src/components/Dialog.svelte
vendored
Normal file
64
vendor/texthooker-ui/src/components/Dialog.svelte
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
export let icon: string | undefined;
|
||||
export let message: string | undefined;
|
||||
export let type = 'info';
|
||||
export let showCancel = true;
|
||||
export let askForData = '';
|
||||
export let dataValue: string | number | undefined;
|
||||
export let callback: <T>(param: { canceled: boolean; data: T }) => void;
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void }>();
|
||||
|
||||
function handleChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
dataValue = target.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed top-12 flex justify-center w-full z-30">
|
||||
<div class="alert shadow-lg max-w-xl" class:alert-info={type === 'info'} class:alert-error={type === 'error'}>
|
||||
<div>
|
||||
{#if icon}
|
||||
<Icon path={icon} />
|
||||
{/if}
|
||||
<span>
|
||||
{#if askForData}
|
||||
<div>
|
||||
{#if askForData === 'text'}
|
||||
<input
|
||||
type={askForData}
|
||||
class="input input-bordered h-8 ml-2"
|
||||
value={dataValue}
|
||||
on:change={handleChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{message}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
{#if showCancel}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
on:click={() => {
|
||||
callback?.({ canceled: true, data: dataValue });
|
||||
dispatch('close');
|
||||
}}>Cancel</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
on:click={() => {
|
||||
callback?.({ canceled: false, data: dataValue });
|
||||
dispatch('close');
|
||||
}}>Confirm</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
36
vendor/texthooker-ui/src/components/DialogManager.svelte
vendored
Normal file
36
vendor/texthooker-ui/src/components/DialogManager.svelte
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { dialogOpen$, openDialog$ } from '../stores/stores';
|
||||
import Dialog from './Dialog.svelte';
|
||||
|
||||
let props: any;
|
||||
let dialogPropsQueue: any[] = [];
|
||||
|
||||
const sub = openDialog$.subscribe((d) => {
|
||||
if (
|
||||
!d ||
|
||||
((d.message.includes('Lost Connection to') || d.message.includes('Unable to connect to')) &&
|
||||
(props?.message === d.message || dialogPropsQueue.find((dialog) => dialog.message === d.message)))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogPropsQueue.unshift(d);
|
||||
|
||||
if (!props) {
|
||||
handleDialog();
|
||||
}
|
||||
});
|
||||
|
||||
function handleDialog() {
|
||||
props = dialogPropsQueue.pop();
|
||||
|
||||
$dialogOpen$ = !!props;
|
||||
}
|
||||
|
||||
onDestroy(() => sub?.unsubscribe());
|
||||
</script>
|
||||
|
||||
{#if props}
|
||||
<Dialog {...props} on:close={handleDialog} />
|
||||
{/if}
|
||||
21
vendor/texthooker-ui/src/components/Icon.svelte
vendored
Normal file
21
vendor/texthooker-ui/src/components/Icon.svelte
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
export let path: string;
|
||||
export let width = '1.5rem';
|
||||
export let height = '1.5rem';
|
||||
export let element: SVGElement | undefined = undefined;
|
||||
export { _class as class };
|
||||
|
||||
let _class = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
style="width: {width}; height: {height};"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class={_class}
|
||||
bind:this={element}
|
||||
on:click
|
||||
on:keyup
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
143
vendor/texthooker-ui/src/components/Line.svelte
vendored
Normal file
143
vendor/texthooker-ui/src/components/Line.svelte
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { mdiTrophy } from '@mdi/js';
|
||||
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import {
|
||||
displayVertical$,
|
||||
enableLineAnimation$,
|
||||
milestoneLines$,
|
||||
preserveWhitespace$,
|
||||
reverseLineOrder$,
|
||||
} from '../stores/stores';
|
||||
import type { LineItem, LineItemEditEvent } from '../types';
|
||||
import { dummyFn, newLineCharacter, updateScroll } from '../util';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
export let line: LineItem;
|
||||
export let index: number;
|
||||
export let isLast: boolean;
|
||||
export let pipWindow: Window = undefined;
|
||||
|
||||
export function deselect() {
|
||||
isSelected = false;
|
||||
}
|
||||
|
||||
export function getIdIfSelected(range: Range) {
|
||||
return isSelected || range.intersectsNode(paragraph) ? line.id : undefined;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{ deselected: string; selected: string; edit: LineItemEditEvent }>();
|
||||
|
||||
let paragraph: HTMLElement;
|
||||
let originalText = '';
|
||||
let isSelected = false;
|
||||
let isEditable = false;
|
||||
|
||||
$: isVerticalDisplay = !pipWindow && $displayVertical$;
|
||||
|
||||
onMount(() => {
|
||||
if (isLast) {
|
||||
updateScroll(
|
||||
pipWindow || window,
|
||||
paragraph.parentElement,
|
||||
$reverseLineOrder$,
|
||||
isVerticalDisplay,
|
||||
$enableLineAnimation$ ? 'smooth' : 'auto',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('click', clickOutsideHandler, false);
|
||||
dispatch('edit', { inEdit: false });
|
||||
});
|
||||
|
||||
function handleDblClick(event: MouseEvent) {
|
||||
if (pipWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (isSelected) {
|
||||
isSelected = false;
|
||||
dispatch('deselected', line.id);
|
||||
} else {
|
||||
isSelected = true;
|
||||
dispatch('selected', line.id);
|
||||
}
|
||||
} else {
|
||||
originalText = paragraph.innerText;
|
||||
isEditable = true;
|
||||
|
||||
dispatch('edit', { inEdit: true });
|
||||
|
||||
document.addEventListener('click', clickOutsideHandler, false);
|
||||
|
||||
tick().then(() => {
|
||||
paragraph.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutsideHandler(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
|
||||
if (!paragraph.contains(target)) {
|
||||
isEditable = false;
|
||||
document.removeEventListener('click', clickOutsideHandler, false);
|
||||
|
||||
dispatch('edit', {
|
||||
inEdit: false,
|
||||
data: { originalText, newText: paragraph.innerText, lineIndex: index, line },
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key line.text}
|
||||
<p
|
||||
class="my-2 cursor-pointer border-2"
|
||||
class:py-4={!isVerticalDisplay}
|
||||
class:px-2={!isVerticalDisplay}
|
||||
class:py-2={isVerticalDisplay}
|
||||
class:px-4={isVerticalDisplay}
|
||||
class:border-transparent={!isSelected}
|
||||
class:cursor-text={isEditable}
|
||||
class:border-primary={isSelected}
|
||||
class:border-accent-focus={isEditable}
|
||||
class:whitespace-pre-wrap={$preserveWhitespace$}
|
||||
contenteditable={isEditable}
|
||||
on:dblclick={handleDblClick}
|
||||
on:keyup={dummyFn}
|
||||
bind:this={paragraph}
|
||||
in:fly={{ x: isVerticalDisplay ? 100 : -100, duration: $enableLineAnimation$ ? 250 : 0 }}
|
||||
>
|
||||
{line.text}
|
||||
</p>
|
||||
{/key}
|
||||
{@html newLineCharacter}
|
||||
{#if $milestoneLines$.has(line.id)}
|
||||
<div
|
||||
class="flex justify-center text-xs my-2 py-2 border-primary border-dashed milestone"
|
||||
class:border-x-2={$displayVertical$}
|
||||
class:border-y-2={!$displayVertical$}
|
||||
class:py-4={!isVerticalDisplay}
|
||||
class:px-2={!isVerticalDisplay}
|
||||
class:py-2={isVerticalDisplay}
|
||||
class:px-4={isVerticalDisplay}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon class={$displayVertical$ ? '' : 'mr-2'} path={mdiTrophy}></Icon>
|
||||
<span class:mt-2={$displayVertical$}>{$milestoneLines$.get(line.id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{@html newLineCharacter}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
p:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
26
vendor/texthooker-ui/src/components/Notes.svelte
vendored
Normal file
26
vendor/texthooker-ui/src/components/Notes.svelte
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { notesOpen$, userNotes$ } from '../stores/stores';
|
||||
import { dummyFn } from '../util';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
function handleBlur(event: FocusEvent) {
|
||||
$userNotes$ = (event.target as HTMLTextAreaElement).value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end p-4">
|
||||
<div
|
||||
class="flex cursor-pointer items-end md:items-center"
|
||||
on:click={() => ($notesOpen$ = false)}
|
||||
on:keyup={dummyFn}
|
||||
>
|
||||
<Icon path={mdiClose} />
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
class="flex-1 overflow-auto ml-10 mr-2 mb-4 p-1 pb-2"
|
||||
style="resize: none;"
|
||||
value={$userNotes$}
|
||||
on:blur={handleBlur}
|
||||
/>
|
||||
330
vendor/texthooker-ui/src/components/Presets.svelte
vendored
Normal file
330
vendor/texthooker-ui/src/components/Presets.svelte
vendored
Normal file
@@ -0,0 +1,330 @@
|
||||
<script lang="ts">
|
||||
import { mdiContentSave, mdiDatabaseSync, mdiDelete, mdiHelpCircle, mdiReload } from '@mdi/js';
|
||||
import { createEventDispatcher, tick } from 'svelte';
|
||||
import {
|
||||
adjustTimerOnAfk$,
|
||||
afkTimer$,
|
||||
allowNewLineDuringPause$,
|
||||
allowPasteDuringPause$,
|
||||
autoStartTimerDuringPause$,
|
||||
autoStartTimerDuringPausePaste$,
|
||||
blockCopyOnPage$,
|
||||
blurStats$,
|
||||
characterMilestone$,
|
||||
continuousReconnect$,
|
||||
customCSS$,
|
||||
defaultSettings,
|
||||
displayVertical$,
|
||||
enableAfkBlur$,
|
||||
enableAfkBlurRestart$,
|
||||
enableExternalClipboardMonitor$,
|
||||
enableLineAnimation$,
|
||||
enablePaste$,
|
||||
filterNonCJKLines$,
|
||||
flashOnMissedLine$,
|
||||
fontSize$,
|
||||
lastSettingPreset$,
|
||||
maxLines$,
|
||||
maxPipLines$,
|
||||
mergeEqualLineStarts$,
|
||||
milestoneLines$,
|
||||
onlineFont$,
|
||||
openDialog$,
|
||||
persistActionHistory$,
|
||||
persistLines$,
|
||||
persistNotes$,
|
||||
persistStats$,
|
||||
preserveWhitespace$,
|
||||
preventGlobalDuplicate$,
|
||||
preventLastDuplicate$,
|
||||
reconnectSecondarySocket$,
|
||||
reconnectSocket$,
|
||||
removeAllWhitespace$,
|
||||
replacements$,
|
||||
reverseLineOrder$,
|
||||
secondarySocketState$,
|
||||
secondaryWebsocketUrl$,
|
||||
settingPresets$,
|
||||
showCharacterCount$,
|
||||
showConnectionErrors$,
|
||||
showConnectionIcon$,
|
||||
showLineCount$,
|
||||
showPresetQuickSwitch$,
|
||||
showSpeed$,
|
||||
showTimer$,
|
||||
skipResetConfirmations$,
|
||||
socketState$,
|
||||
theme$,
|
||||
websocketUrl$,
|
||||
windowTitle$,
|
||||
} from '../stores/stores';
|
||||
import type { DialogResult, SettingPreset, Settings } from '../types';
|
||||
import { dummyFn } from '../util';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
export let isQuickSwitch = false;
|
||||
|
||||
export function getCurrentSettings(): Settings {
|
||||
return {
|
||||
theme$: $theme$,
|
||||
replacements$: $replacements$,
|
||||
windowTitle$: $windowTitle$,
|
||||
websocketUrl$: $websocketUrl$,
|
||||
secondaryWebsocketUrl$: $secondaryWebsocketUrl$,
|
||||
fontSize$: $fontSize$,
|
||||
characterMilestone$: $characterMilestone$,
|
||||
onlineFont$: $onlineFont$,
|
||||
preventLastDuplicate$: $preventLastDuplicate$,
|
||||
maxLines$: $maxLines$,
|
||||
maxPipLines$: $maxPipLines$,
|
||||
afkTimer$: $afkTimer$,
|
||||
adjustTimerOnAfk$: $adjustTimerOnAfk$,
|
||||
enableExternalClipboardMonitor$: $enableExternalClipboardMonitor$,
|
||||
showPresetQuickSwitch$: $showPresetQuickSwitch$,
|
||||
skipResetConfirmations$: $skipResetConfirmations$,
|
||||
persistStats$: $persistStats$,
|
||||
persistNotes$: $persistNotes$,
|
||||
persistLines$: $persistLines$,
|
||||
persistActionHistory$: $persistActionHistory$,
|
||||
enablePaste$: $enablePaste$,
|
||||
blockCopyOnPage$: $blockCopyOnPage$,
|
||||
allowPasteDuringPause$: $allowPasteDuringPause$,
|
||||
allowNewLineDuringPause$: $allowNewLineDuringPause$,
|
||||
autoStartTimerDuringPausePaste$: $autoStartTimerDuringPausePaste$,
|
||||
autoStartTimerDuringPause$: $autoStartTimerDuringPause$,
|
||||
preventGlobalDuplicate$: $preventGlobalDuplicate$,
|
||||
mergeEqualLineStarts$: $mergeEqualLineStarts$,
|
||||
filterNonCJKLines: $filterNonCJKLines$,
|
||||
flashOnMissedLine$: $flashOnMissedLine$,
|
||||
displayVertical$: $displayVertical$,
|
||||
reverseLineOrder$: $reverseLineOrder$,
|
||||
preserveWhitespace$: $preserveWhitespace$,
|
||||
removeAllWhitespace$: $removeAllWhitespace$,
|
||||
showTimer$: $showTimer$,
|
||||
showSpeed$: $showSpeed$,
|
||||
showCharacterCount$: $showCharacterCount$,
|
||||
showLineCount$: $showLineCount$,
|
||||
blurStats$: $blurStats$,
|
||||
enableLineAnimation$: $enableLineAnimation$,
|
||||
enableAfkBlur$: $enableAfkBlur$,
|
||||
enableAfkBlurRestart$: $enableAfkBlurRestart$,
|
||||
continuousReconnect$: $continuousReconnect$,
|
||||
showConnectionErrors$: $showConnectionErrors$,
|
||||
showConnectionIcon$: $showConnectionIcon$,
|
||||
customCSS$: $customCSS$,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSettingsWithPreset(preset: SettingPreset, updateLastPreset = true) {
|
||||
theme$.next(preset.settings.theme$ ?? defaultSettings.theme$);
|
||||
replacements$.next(preset.settings.replacements$ ?? defaultSettings.replacements$);
|
||||
windowTitle$.next(preset.settings.windowTitle$ ?? defaultSettings.windowTitle$);
|
||||
websocketUrl$.next(preset.settings.websocketUrl$ ?? defaultSettings.websocketUrl$);
|
||||
secondaryWebsocketUrl$.next(preset.settings.secondaryWebsocketUrl$ ?? '');
|
||||
fontSize$.next(preset.settings.fontSize$ ?? defaultSettings.fontSize$);
|
||||
characterMilestone$.next(preset.settings.characterMilestone$ ?? defaultSettings.characterMilestone$);
|
||||
onlineFont$.next(preset.settings.onlineFont$ ?? defaultSettings.onlineFont$);
|
||||
preventLastDuplicate$.next(preset.settings.preventLastDuplicate$ ?? defaultSettings.preventLastDuplicate$);
|
||||
maxLines$.next(preset.settings.maxLines$ ?? defaultSettings.maxLines$);
|
||||
maxPipLines$.next(preset.settings.maxPipLines$ ?? defaultSettings.maxPipLines$);
|
||||
afkTimer$.next(preset.settings.afkTimer$ ?? defaultSettings.afkTimer$);
|
||||
adjustTimerOnAfk$.next(preset.settings.adjustTimerOnAfk$ ?? defaultSettings.adjustTimerOnAfk$);
|
||||
enableExternalClipboardMonitor$.next(
|
||||
preset.settings.enableExternalClipboardMonitor$ ?? defaultSettings.enableExternalClipboardMonitor$,
|
||||
);
|
||||
showPresetQuickSwitch$.next(preset.settings.showPresetQuickSwitch$ ?? defaultSettings.showPresetQuickSwitch$);
|
||||
skipResetConfirmations$.next(
|
||||
preset.settings.skipResetConfirmations$ ?? defaultSettings.skipResetConfirmations$,
|
||||
);
|
||||
persistStats$.next(preset.settings.persistStats$ ?? defaultSettings.persistStats$);
|
||||
persistNotes$.next(preset.settings.persistNotes$ ?? defaultSettings.persistNotes$);
|
||||
persistLines$.next(preset.settings.persistLines$ ?? defaultSettings.persistLines$);
|
||||
persistActionHistory$.next(preset.settings.persistActionHistory$ ?? defaultSettings.persistActionHistory$);
|
||||
enablePaste$.next(preset.settings.enablePaste$ ?? defaultSettings.enablePaste$);
|
||||
blockCopyOnPage$.next(preset.settings.blockCopyOnPage$ ?? defaultSettings.blockCopyOnPage$);
|
||||
allowPasteDuringPause$.next(preset.settings.allowPasteDuringPause$ ?? defaultSettings.allowPasteDuringPause$);
|
||||
allowNewLineDuringPause$.next(
|
||||
preset.settings.allowNewLineDuringPause$ ?? defaultSettings.allowNewLineDuringPause$,
|
||||
);
|
||||
autoStartTimerDuringPausePaste$.next(
|
||||
preset.settings.autoStartTimerDuringPausePaste$ ?? defaultSettings.autoStartTimerDuringPausePaste$,
|
||||
);
|
||||
autoStartTimerDuringPause$.next(
|
||||
preset.settings.autoStartTimerDuringPause$ ?? defaultSettings.autoStartTimerDuringPause$,
|
||||
);
|
||||
preventGlobalDuplicate$.next(
|
||||
preset.settings.preventGlobalDuplicate$ ?? defaultSettings.preventGlobalDuplicate$,
|
||||
);
|
||||
mergeEqualLineStarts$.next(preset.settings.mergeEqualLineStarts$ ?? defaultSettings.mergeEqualLineStarts$);
|
||||
filterNonCJKLines$.next(preset.settings.filterNonCJKLines ?? defaultSettings.filterNonCJKLines);
|
||||
flashOnMissedLine$.next(preset.settings.flashOnMissedLine$ ?? defaultSettings.flashOnMissedLine$);
|
||||
displayVertical$.next(preset.settings.displayVertical$ ?? defaultSettings.displayVertical$);
|
||||
reverseLineOrder$.next(preset.settings.reverseLineOrder$ ?? defaultSettings.reverseLineOrder$);
|
||||
preserveWhitespace$.next(preset.settings.preserveWhitespace$ ?? defaultSettings.preserveWhitespace$);
|
||||
removeAllWhitespace$.next(preset.settings.removeAllWhitespace$ ?? defaultSettings.removeAllWhitespace$);
|
||||
showTimer$.next(preset.settings.showTimer$ ?? defaultSettings.showTimer$);
|
||||
showSpeed$.next(preset.settings.showSpeed$ ?? defaultSettings.showSpeed$);
|
||||
showCharacterCount$.next(preset.settings.showCharacterCount$ ?? defaultSettings.showCharacterCount$);
|
||||
showLineCount$.next(preset.settings.showLineCount$ ?? defaultSettings.showLineCount$);
|
||||
blurStats$.next(preset.settings.blurStats$ ?? defaultSettings.blurStats$);
|
||||
enableLineAnimation$.next(preset.settings.enableLineAnimation$ ?? defaultSettings.enableLineAnimation$);
|
||||
enableAfkBlur$.next(preset.settings.enableAfkBlur$ ?? defaultSettings.enableAfkBlur$);
|
||||
enableAfkBlurRestart$.next(preset.settings.enableAfkBlurRestart$ ?? defaultSettings.enableAfkBlurRestart$);
|
||||
continuousReconnect$.next(preset.settings.continuousReconnect$ ?? defaultSettings.continuousReconnect$);
|
||||
showConnectionErrors$.next(preset.settings.showConnectionErrors$ ?? defaultSettings.showConnectionErrors$);
|
||||
showConnectionIcon$.next(preset.settings.showConnectionIcon$ ?? defaultSettings.showConnectionIcon$);
|
||||
customCSS$.next(preset.settings.customCSS$ ?? defaultSettings.customCSS$);
|
||||
|
||||
if (updateLastPreset) {
|
||||
$lastSettingPreset$ = preset.name;
|
||||
}
|
||||
|
||||
if ($characterMilestone$ === 0) {
|
||||
$milestoneLines$ = new Map<string, string>();
|
||||
}
|
||||
|
||||
tick().then(() => {
|
||||
dispatch('layoutChange');
|
||||
|
||||
if ($socketState$ !== 1 && $continuousReconnect$) {
|
||||
reconnectSocket$.next();
|
||||
}
|
||||
|
||||
if ($secondarySocketState$ !== 1 && $continuousReconnect$) {
|
||||
reconnectSecondarySocket$.next();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fallbackPresetEntry = [{ name: '' }];
|
||||
const dispatch = createEventDispatcher<{ layoutChange: void; exportImportPreset: MouseEvent }>();
|
||||
|
||||
function selectPreset(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
|
||||
changePreset(target.selectedOptions[0].value);
|
||||
}
|
||||
|
||||
function changePreset(presetName: string) {
|
||||
const existingEntry = $settingPresets$.find((entry) => entry.name === presetName);
|
||||
|
||||
if (existingEntry) {
|
||||
updateSettingsWithPreset(existingEntry);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePreset() {
|
||||
const { canceled, data } = await new Promise<DialogResult<string>>((resolve) => {
|
||||
$openDialog$ = {
|
||||
icon: mdiHelpCircle,
|
||||
askForData: 'text',
|
||||
dataValue: $lastSettingPreset$ || 'Preset Name',
|
||||
message: '',
|
||||
callback: resolve,
|
||||
};
|
||||
});
|
||||
|
||||
if (canceled || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEntryIndex = $settingPresets$.findIndex((entry) => entry.name === data);
|
||||
const entry: SettingPreset = {
|
||||
name: data,
|
||||
settings: getCurrentSettings(),
|
||||
};
|
||||
|
||||
if (existingEntryIndex > -1) {
|
||||
$settingPresets$[existingEntryIndex] = entry;
|
||||
} else {
|
||||
$settingPresets$ = [...$settingPresets$, entry];
|
||||
}
|
||||
|
||||
$lastSettingPreset$ = data;
|
||||
}
|
||||
|
||||
async function deletePreset() {
|
||||
if (!$skipResetConfirmations$) {
|
||||
const { canceled } = await new Promise<DialogResult>((resolve) => {
|
||||
$openDialog$ = {
|
||||
icon: mdiHelpCircle,
|
||||
message: 'Preset will be deleted',
|
||||
callback: resolve,
|
||||
};
|
||||
});
|
||||
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$settingPresets$ = $settingPresets$.filter((entry) => entry.name !== $lastSettingPreset$);
|
||||
$lastSettingPreset$ = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isQuickSwitch}
|
||||
<select
|
||||
class="w-48 hidden sm:block"
|
||||
class:sm:hidden={!$showPresetQuickSwitch$ || $settingPresets$.length < 2}
|
||||
value={$lastSettingPreset$}
|
||||
on:change={selectPreset}
|
||||
>
|
||||
{#each $settingPresets$.length ? $settingPresets$ : fallbackPresetEntry as preset (preset.name)}
|
||||
<option value={preset.name}>
|
||||
{preset.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<details role="button" class="col-span-4 mb-2">
|
||||
<summary>Presets</summary>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<select class="select flex-1 max-w-md" value={$lastSettingPreset$} on:change={selectPreset}>
|
||||
{#each $settingPresets$.length ? $settingPresets$ : fallbackPresetEntry as preset (preset.name)}
|
||||
<option value={preset.name}>
|
||||
{preset.name || 'No Presets stored'}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div
|
||||
role="button"
|
||||
class="flex flex-col items-center hover:text-primary ml-3"
|
||||
on:click={savePreset}
|
||||
on:keyup={dummyFn}
|
||||
>
|
||||
<Icon path={mdiContentSave} />
|
||||
<span class="label-text">Save</span>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
class="flex flex-col items-center hover:text-primary ml-3"
|
||||
on:click={(event) => dispatch('exportImportPreset', event)}
|
||||
on:keyup={dummyFn}
|
||||
>
|
||||
<Icon path={mdiDatabaseSync} />
|
||||
<span class="label-text">Export/Import</span>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
class="flex flex-col items-center hover:text-primary ml-3"
|
||||
class:invisible={!$lastSettingPreset$}
|
||||
on:click={() => changePreset($lastSettingPreset$)}
|
||||
on:keyup={dummyFn}
|
||||
>
|
||||
<Icon path={mdiReload} />
|
||||
<span class="label-text">Reload</span>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
class="flex flex-col items-center hover:text-primary ml-3"
|
||||
class:invisible={!$lastSettingPreset$}
|
||||
on:click={deletePreset}
|
||||
on:keyup={dummyFn}
|
||||
>
|
||||
<Icon path={mdiDelete} />
|
||||
<span class="label-text">Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
52
vendor/texthooker-ui/src/components/ReplacementSettings.svelte
vendored
Normal file
52
vendor/texthooker-ui/src/components/ReplacementSettings.svelte
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { replacements$ } from '../stores/stores';
|
||||
import type { ReplacementItem } from '../types';
|
||||
import Icon from './Icon.svelte';
|
||||
import ReplacementSettingsInput from './ReplacementSettingsInput.svelte';
|
||||
import ReplacementSettingsList from './ReplacementSettingsList.svelte';
|
||||
|
||||
let inEditMode = false;
|
||||
|
||||
let currentReplacement: ReplacementItem | undefined;
|
||||
|
||||
$: hasReplacements = !!$replacements$.length;
|
||||
|
||||
$: resetEditMode($replacements$);
|
||||
|
||||
function resetEditMode(_replacements: ReplacementItem[]) {
|
||||
inEditMode = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<details class="col-span-4 mb-2 cursor-pointer max-w-lg">
|
||||
<summary>Replacements</summary>
|
||||
<div class="mb-8">
|
||||
{#if inEditMode}
|
||||
<ReplacementSettingsInput
|
||||
{currentReplacement}
|
||||
on:close={() => {
|
||||
inEditMode = false;
|
||||
currentReplacement = undefined;
|
||||
}}
|
||||
/>
|
||||
{:else if hasReplacements}
|
||||
{#key $replacements$}
|
||||
<ReplacementSettingsList
|
||||
on:edit={({ detail }) => {
|
||||
currentReplacement = detail;
|
||||
inEditMode = true;
|
||||
}}
|
||||
on:applyReplacements
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="flex justify-end my-2">
|
||||
<button title="Add replacement" class="ml-2 hover:text-primary" on:click={() => (inEditMode = true)}>
|
||||
<Icon path={mdiPlus} />
|
||||
</button>
|
||||
</div>
|
||||
<div>You have currently no replacements configured</div>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
132
vendor/texthooker-ui/src/components/ReplacementSettingsInput.svelte
vendored
Normal file
132
vendor/texthooker-ui/src/components/ReplacementSettingsInput.svelte
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { mdiCancel, mdiFloppy, mdiInformation } from '@mdi/js';
|
||||
import { debounceTime, Subject, tap } from 'rxjs';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { replacements$ } from '../stores/stores';
|
||||
import type { ReplacementItem } from '../types';
|
||||
import { applyReplacements, reduceToEmptyString } from '../util';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
export let currentReplacement: ReplacementItem | undefined;
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void }>();
|
||||
|
||||
const applyPattern$ = new Subject<void>();
|
||||
const replacement: ReplacementItem = currentReplacement
|
||||
? JSON.parse(JSON.stringify(currentReplacement))
|
||||
: { pattern: '', replaces: '', flags: [], enabled: true };
|
||||
const flags = [
|
||||
{ label: 'global', value: 'g' },
|
||||
{ label: 'multiline', value: 'm' },
|
||||
{ label: 'insensitive', value: 'i' },
|
||||
{ label: 'unicode', value: 'u' },
|
||||
];
|
||||
|
||||
const executePattern$ = applyPattern$.pipe(
|
||||
debounceTime(500),
|
||||
tap(() => {
|
||||
try {
|
||||
currentPatternError = '';
|
||||
|
||||
if (!replacement.pattern || !currentTestValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTestOutcome = applyReplacements(currentTestValue, [replacement]);
|
||||
} catch ({ message }) {
|
||||
currentPatternError = `Error: ${message}`;
|
||||
}
|
||||
}),
|
||||
reduceToEmptyString(),
|
||||
);
|
||||
|
||||
let patternInput: HTMLInputElement;
|
||||
let currentTestValue = '';
|
||||
let currentTestOutcome = '';
|
||||
let currentPatternError = '';
|
||||
|
||||
function onExecutePattern() {
|
||||
patternInput.setCustomValidity(currentPatternError);
|
||||
|
||||
applyPattern$.next();
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
if (!currentReplacement && $replacements$.find((entry) => entry.pattern === replacement.pattern)) {
|
||||
patternInput.setCustomValidity('This pattern already exists');
|
||||
|
||||
return patternInput.reportValidity();
|
||||
}
|
||||
|
||||
if (currentReplacement) {
|
||||
$replacements$ = $replacements$.map((entry) => {
|
||||
if (entry.pattern === currentReplacement.pattern) {
|
||||
return replacement;
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
} else {
|
||||
$replacements$ = [...$replacements$, replacement];
|
||||
}
|
||||
|
||||
dispatch('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
{$executePattern$ ?? ''}
|
||||
<div class="flex justify-end my-2">
|
||||
{#if replacement.pattern}
|
||||
<button title="Save" class="hover:text-primary" on:click={onSave}>
|
||||
<Icon path={mdiFloppy} />
|
||||
</button>
|
||||
{/if}
|
||||
<button title="Cancel" class="ml-2 hover:text-primary" on:click={() => dispatch('close')}>
|
||||
<Icon path={mdiCancel} />
|
||||
</button>
|
||||
<button
|
||||
title="Cancel"
|
||||
class="ml-2 hover:text-primary"
|
||||
on:click={() =>
|
||||
window.open(
|
||||
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#description',
|
||||
'_blank',
|
||||
)}
|
||||
>
|
||||
<Icon path={mdiInformation} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
placeholder="text pattern"
|
||||
class="w-full my-2"
|
||||
bind:value={replacement.pattern}
|
||||
bind:this={patternInput}
|
||||
on:input={onExecutePattern}
|
||||
/>
|
||||
<input
|
||||
placeholder="replacement pattern"
|
||||
class="w-full my-2"
|
||||
bind:value={replacement.replaces}
|
||||
on:input={onExecutePattern}
|
||||
/>
|
||||
<div class="flex justify-between my-4">
|
||||
{#each flags as flag (flag.value)}
|
||||
<label>
|
||||
<input type="checkbox" value={flag.value} bind:group={replacement.flags} on:change={onExecutePattern} />
|
||||
{flag.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<textarea
|
||||
name="test-value"
|
||||
placeholder="test value"
|
||||
class="w-full my-4"
|
||||
rows="3"
|
||||
bind:value={currentTestValue}
|
||||
on:input={onExecutePattern}
|
||||
/>
|
||||
<div class="whitespace-pre-wrap break-all">
|
||||
{@html currentPatternError || currentTestOutcome}
|
||||
</div>
|
||||
</div>
|
||||
118
vendor/texthooker-ui/src/components/ReplacementSettingsList.svelte
vendored
Normal file
118
vendor/texthooker-ui/src/components/ReplacementSettingsList.svelte
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
mdiDownloadMultiple,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
mdiToggleSwitchOffOutline,
|
||||
mdiToggleSwitchOutline,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import Sortable, { Swap } from 'sortablejs';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { enabledReplacements$, lineData$, replacements$ } from '../stores/stores';
|
||||
import type { ReplacementItem } from '../types';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher<{ edit: ReplacementItem | undefined; applyReplacements: void }>();
|
||||
|
||||
let sortableInstance: Sortable;
|
||||
let listContainer: HTMLDivElement;
|
||||
let listItems = JSON.parse(JSON.stringify($replacements$));
|
||||
|
||||
$: canApplyReplacements = !!$lineData$.length && !!$enabledReplacements$.length;
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
Sortable.mount(new Swap());
|
||||
} catch (_) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
sortableInstance = Sortable.create(listContainer, {
|
||||
swap: true,
|
||||
swapClass: 'swap',
|
||||
animation: 150,
|
||||
store: {
|
||||
get: getSortableList,
|
||||
set: onUpdateList,
|
||||
},
|
||||
});
|
||||
|
||||
return () => sortableInstance?.destroy();
|
||||
});
|
||||
|
||||
function onToggle(newValue: boolean) {
|
||||
const sortedList = getSortedList();
|
||||
|
||||
listItems = sortedList.map((replacement) => ({ ...replacement, enabled: newValue }));
|
||||
$replacements$ = listItems;
|
||||
}
|
||||
|
||||
function onUpdateList() {
|
||||
$replacements$ = getSortedList();
|
||||
}
|
||||
|
||||
function onReplaceItems(newReplacements: ReplacementItem[]) {
|
||||
listItems = newReplacements;
|
||||
$replacements$ = newReplacements;
|
||||
|
||||
sortableInstance.sort(getSortableList(), false);
|
||||
}
|
||||
|
||||
function getSortableList() {
|
||||
return [...listItems.map((replacments) => replacments.pattern)];
|
||||
}
|
||||
|
||||
function getSortedList() {
|
||||
const sortedList = sortableInstance.toArray();
|
||||
|
||||
return listItems.slice().sort((a, b) => sortedList.indexOf(a.pattern) - sortedList.indexOf(b.pattern));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end my-2">
|
||||
<button
|
||||
title="Apply to current lines"
|
||||
class:hover:text-primary={canApplyReplacements}
|
||||
class:cursor-not-allowed={!canApplyReplacements}
|
||||
disabled={!canApplyReplacements}
|
||||
on:click={() => dispatch('applyReplacements')}
|
||||
>
|
||||
<Icon path={mdiDownloadMultiple} />
|
||||
</button>
|
||||
<button title="Add replacement" class="ml-2 hover:text-primary" on:click={() => dispatch('edit', undefined)}>
|
||||
<Icon path={mdiPlus} />
|
||||
</button>
|
||||
<button title="Enable all" class="ml-2 hover:text-primary" on:click={() => onToggle(true)}>
|
||||
<Icon path={mdiToggleSwitchOutline} />
|
||||
</button>
|
||||
<button title="Disable all" class="ml-2 hover:text-primary" on:click={() => onToggle(false)}>
|
||||
<Icon path={mdiToggleSwitchOffOutline} />
|
||||
</button>
|
||||
<button title="Remove all" class="ml-2 hover:text-primary" on:click={() => onReplaceItems([])}>
|
||||
<Icon path={mdiTrashCanOutline} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto" bind:this={listContainer}>
|
||||
{#each listItems as replacement (replacement.pattern)}
|
||||
<div class="border my-2 p-2 flex items-center justify-between" data-id={replacement.pattern}>
|
||||
<div class="break-all">
|
||||
{replacement.pattern}
|
||||
</div>
|
||||
<div class="min-w-max ml-2">
|
||||
<button title="Edit" class="hover:text-primary" on:click={() => dispatch('edit', replacement)}>
|
||||
<Icon path={mdiPencil} height="1rem" />
|
||||
</button>
|
||||
<button
|
||||
title="Remove"
|
||||
class="hover:text-primary"
|
||||
on:click={() =>
|
||||
onReplaceItems($replacements$.filter((entry) => entry.pattern !== replacement.pattern))}
|
||||
>
|
||||
<Icon path={mdiTrashCanOutline} height="1rem" />
|
||||
</button>
|
||||
<input type="checkbox" class="ml-1" bind:checked={replacement.enabled} on:change={onUpdateList} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
1013
vendor/texthooker-ui/src/components/Settings.svelte
vendored
Normal file
1013
vendor/texthooker-ui/src/components/Settings.svelte
vendored
Normal file
File diff suppressed because it is too large
Load Diff
121
vendor/texthooker-ui/src/components/SocketConnector.svelte
vendored
Normal file
121
vendor/texthooker-ui/src/components/SocketConnector.svelte
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { mdiConnection } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { SocketConnection } from '../socket';
|
||||
import {
|
||||
continuousReconnect$,
|
||||
isPaused$,
|
||||
openDialog$,
|
||||
reconnectSecondarySocket$,
|
||||
reconnectSocket$,
|
||||
secondarySocketState$,
|
||||
secondaryWebsocketUrl$,
|
||||
showConnectionErrors$,
|
||||
showConnectionIcon$,
|
||||
socketState$,
|
||||
websocketUrl$,
|
||||
} from '../stores/stores';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
export let isPrimary = true;
|
||||
|
||||
let socketConnection: SocketConnection | undefined;
|
||||
let intitialAttemptDone = false;
|
||||
let wasConnected = false;
|
||||
let closeRequested = false;
|
||||
let socketState = isPrimary ? socketState$ : secondarySocketState$;
|
||||
|
||||
$: connectedWithLabel = updateConnectedWithLabel(wasConnected);
|
||||
|
||||
$: handleSocketState($socketState);
|
||||
|
||||
onMount(() => {
|
||||
toggleSocket();
|
||||
|
||||
return () => {
|
||||
closeRequested = true;
|
||||
socketConnection?.cleanUp();
|
||||
};
|
||||
});
|
||||
|
||||
function handleSocketState(socketStateValue) {
|
||||
switch (socketStateValue) {
|
||||
case 0:
|
||||
wasConnected = false;
|
||||
closeRequested = false;
|
||||
break;
|
||||
case 1:
|
||||
intitialAttemptDone = true;
|
||||
wasConnected = true;
|
||||
break;
|
||||
case 3:
|
||||
const socketType = isPrimary ? 'primary' : 'secondary';
|
||||
const socketUrl = isPrimary ? $websocketUrl$ : $secondaryWebsocketUrl$;
|
||||
|
||||
if (
|
||||
$showConnectionErrors$ &&
|
||||
!closeRequested &&
|
||||
intitialAttemptDone &&
|
||||
socketUrl &&
|
||||
(wasConnected || !$continuousReconnect$)
|
||||
) {
|
||||
$openDialog$ = {
|
||||
type: 'error',
|
||||
message: wasConnected
|
||||
? `Lost Connection to ${socketType} Websocket`
|
||||
: `Unable to connect to ${socketType} Websocket`,
|
||||
showCancel: false,
|
||||
};
|
||||
}
|
||||
|
||||
$isPaused$ = true;
|
||||
|
||||
intitialAttemptDone = true;
|
||||
wasConnected = false;
|
||||
|
||||
if (!closeRequested) {
|
||||
(isPrimary ? reconnectSocket$ : reconnectSecondarySocket$).next();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
connectedWithLabel = updateConnectedWithLabel(wasConnected);
|
||||
}
|
||||
|
||||
function updateConnectedWithLabel(hasConnection: boolean) {
|
||||
return hasConnection
|
||||
? `Connected with ${isPrimary ? $websocketUrl$ : $secondaryWebsocketUrl$}`
|
||||
: 'Not Connected';
|
||||
}
|
||||
|
||||
async function toggleSocket() {
|
||||
if ($socketState === 1 && socketConnection) {
|
||||
closeRequested = true;
|
||||
socketConnection.disconnect();
|
||||
} else {
|
||||
socketConnection = socketConnection || new SocketConnection(isPrimary);
|
||||
socketConnection.connect();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $socketState !== 0}
|
||||
<div
|
||||
class="hover:text-primary"
|
||||
class:text-red-500={$socketState !== -1}
|
||||
class:text-green-700={$socketState === 1}
|
||||
class:hidden={!$showConnectionIcon$}
|
||||
title={connectedWithLabel}
|
||||
>
|
||||
<Icon path={mdiConnection} class="cursor-pointer mx-2" on:click={toggleSocket} />
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
class="animate-ping relative inline-flex rounded-full h-3 w-3 mx-3 bg-primary"
|
||||
class:hidden={!$showConnectionIcon$}
|
||||
/>
|
||||
{/if}
|
||||
23
vendor/texthooker-ui/src/components/Spinner.svelte
vendored
Normal file
23
vendor/texthooker-ui/src/components/Spinner.svelte
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="tap-highlight-transparent absolute inset-0 bg-black/[.3] z-20" />
|
||||
<div class="fixed inset-0 flex h-full w-full items-center justify-center z-50">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
216
vendor/texthooker-ui/src/components/Stats.svelte
vendored
Normal file
216
vendor/texthooker-ui/src/components/Stats.svelte
vendored
Normal file
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
fromEvent,
|
||||
interval,
|
||||
merge,
|
||||
NEVER,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
throttleTime,
|
||||
} from 'rxjs';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import {
|
||||
adjustTimerOnAfk$,
|
||||
afkTimer$,
|
||||
blurStats$,
|
||||
characterMilestone$,
|
||||
enableAfkBlur$,
|
||||
enableAfkBlurRestart$,
|
||||
isPaused$,
|
||||
lineData$,
|
||||
milestoneLines$,
|
||||
newLine$,
|
||||
showCharacterCount$,
|
||||
showLineCount$,
|
||||
showSpeed$,
|
||||
showTimer$,
|
||||
timeValue$,
|
||||
} from '../stores/stores';
|
||||
import { reduceToEmptyString, toTimeString } from '../util';
|
||||
|
||||
let lastTick = 0;
|
||||
let idleTime = 0;
|
||||
|
||||
const dispatch = createEventDispatcher<{ afkBlur: boolean }>();
|
||||
|
||||
const isNotJapaneseRegex = /[^0-9A-Z○◯々-〇〻ぁ-ゖゝ-ゞァ-ヺー0-9A-Zヲ-ン\p{Radical}\p{Unified_Ideograph}]+/gimu;
|
||||
|
||||
const timer$ = isPaused$.pipe(
|
||||
switchMap((isPaused) => {
|
||||
if (isPaused) {
|
||||
idleTime = 0;
|
||||
|
||||
return NEVER;
|
||||
}
|
||||
|
||||
lastTick = performance.now();
|
||||
|
||||
return interval(1000);
|
||||
}),
|
||||
tap(updateElapsedTime),
|
||||
reduceToEmptyString(),
|
||||
);
|
||||
|
||||
const waitForIdle$ = combineLatest([isPaused$, afkTimer$]).pipe(
|
||||
switchMap(([isPaused, afkTimer]) =>
|
||||
isPaused || afkTimer < 1
|
||||
? NEVER
|
||||
: merge(
|
||||
newLine$,
|
||||
fromEvent<PointerEvent>(window, 'pointermove'),
|
||||
fromEvent<Event>(document, 'selectionchange'),
|
||||
).pipe(
|
||||
startWith(true),
|
||||
throttleTime(1000),
|
||||
tap(() => (idleTime = performance.now() + $afkTimer$ * 1000)),
|
||||
debounceTime($afkTimer$ * 1000),
|
||||
),
|
||||
),
|
||||
reduceToEmptyString(),
|
||||
);
|
||||
|
||||
let timerElm: HTMLElement;
|
||||
let speed = 0;
|
||||
let characters = 0;
|
||||
let statstring = '';
|
||||
|
||||
$: if (($showCharacterCount$ || $characterMilestone$ > 1) && $lineData$) {
|
||||
const newMilestoneLines = new Map<string, string>();
|
||||
|
||||
let newCount = 0;
|
||||
let nextMilestone = $characterMilestone$ > 1 ? $characterMilestone$ : 0;
|
||||
|
||||
for (let index = 0, { length } = $lineData$; index < length; index += 1) {
|
||||
newCount += getCharacterCount($lineData$[index].text);
|
||||
|
||||
if (nextMilestone && newCount >= nextMilestone) {
|
||||
let currentCount = newCount;
|
||||
let achievedMilestone = nextMilestone;
|
||||
|
||||
newMilestoneLines.set($lineData$[index].id, `Milestone ${achievedMilestone} (${newCount})`);
|
||||
|
||||
while (currentCount >= nextMilestone) {
|
||||
nextMilestone += $characterMilestone$;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$milestoneLines$ = newMilestoneLines;
|
||||
|
||||
characters = newCount;
|
||||
speed = $timeValue$ ? Math.ceil((3600 * characters) / $timeValue$) : 0;
|
||||
}
|
||||
|
||||
$: if ($timeValue$ > -1 && ($showTimer$ || $showSpeed$ || $showCharacterCount$ || $showLineCount$)) {
|
||||
buildString($timeValue$, speed, characters, $lineData$.length);
|
||||
} else {
|
||||
statstring = '';
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection?.toString() && selection.getRangeAt(0).intersectsNode(timerElm)) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
function updateElapsedTime() {
|
||||
const now = idleTime ? Math.min(idleTime, performance.now()) : performance.now();
|
||||
const elapsed = Math.round((now - lastTick + Number.EPSILON) / 1000);
|
||||
|
||||
if (idleTime && now >= idleTime) {
|
||||
$isPaused$ = true;
|
||||
|
||||
if ($adjustTimerOnAfk$) {
|
||||
$timeValue$ = Math.max(0, $timeValue$ + elapsed - $afkTimer$);
|
||||
} else {
|
||||
$timeValue$ += elapsed;
|
||||
}
|
||||
|
||||
if ($enableAfkBlur$) {
|
||||
dispatch('afkBlur', true);
|
||||
|
||||
document.addEventListener(
|
||||
'dblclick',
|
||||
(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
window.getSelection().removeAllRanges();
|
||||
|
||||
dispatch('afkBlur', false);
|
||||
|
||||
if ($enableAfkBlurRestart$) {
|
||||
$isPaused$ = false;
|
||||
}
|
||||
},
|
||||
{ once: true, capture: false },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lastTick = now;
|
||||
$timeValue$ += elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
function getCharacterCount(text: string) {
|
||||
if (!text) return 0;
|
||||
return countUnicodeCharacters(text.replace(isNotJapaneseRegex, ''));
|
||||
}
|
||||
|
||||
function countUnicodeCharacters(s: string) {
|
||||
return Array.from(s).length;
|
||||
}
|
||||
|
||||
function buildString(currentTime: number, currentSpeed: number, currentCharacters: number, currentLines: number) {
|
||||
let newString = '';
|
||||
|
||||
if ($showTimer$) {
|
||||
newString += toTimeString(currentTime);
|
||||
}
|
||||
|
||||
if ($showSpeed$) {
|
||||
newString += ` (${currentSpeed}/h) `;
|
||||
}
|
||||
|
||||
if ($showCharacterCount$) {
|
||||
newString += ` ${currentCharacters}`;
|
||||
}
|
||||
|
||||
if ($showLineCount$) {
|
||||
newString += $showCharacterCount$ ? ' /' : '';
|
||||
newString += ` ${currentLines}`;
|
||||
}
|
||||
|
||||
statstring = newString.replace(/[ ]+/g, ' ').trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
{$timer$ ?? ''}
|
||||
{$waitForIdle$ ?? ''}
|
||||
|
||||
<div
|
||||
class="text-sm timer mr-1 sm:text-base sm:mr-2"
|
||||
class:blur={$blurStats$}
|
||||
bind:this={timerElm}
|
||||
on:pointerleave={handlePointerLeave}
|
||||
>
|
||||
<div>{statstring}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timer {
|
||||
transition: 0.1s filter linear;
|
||||
}
|
||||
|
||||
.blur:hover {
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.blur:not(:hover) {
|
||||
filter: blur(0.25rem);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user