Merge pull request #2 from ZXY101/anki-connect

Add anki connect integration
This commit is contained in:
Shaun Tenner
2023-09-25 07:49:41 +02:00
committed by GitHub
23 changed files with 7288 additions and 20523 deletions

File diff suppressed because it is too large Load Diff

13
package-lock.json generated
View File

@@ -10,7 +10,8 @@
"dependencies": {
"@zip.js/zip.js": "^2.7.20",
"dexie": "^4.0.1-alpha.25",
"panzoom": "^9.4.3"
"panzoom": "^9.4.3",
"svelte-easy-crop": "^2.0.1"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
@@ -3440,6 +3441,11 @@
"svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0"
}
},
"node_modules/svelte-easy-crop": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/svelte-easy-crop/-/svelte-easy-crop-2.0.1.tgz",
"integrity": "sha512-5cB5wif3DuP92u0etdxk3u0fbGwyNq5G4Dd454g3LeMdVn15oim2rlWPNcbgH5Jr1AJZI5k4Bsp70zbQ0lpNtg=="
},
"node_modules/svelte-eslint-parser": {
"version": "0.32.2",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.32.2.tgz",
@@ -6301,6 +6307,11 @@
"typescript": "^5.0.3"
}
},
"svelte-easy-crop": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/svelte-easy-crop/-/svelte-easy-crop-2.0.1.tgz",
"integrity": "sha512-5cB5wif3DuP92u0etdxk3u0fbGwyNq5G4Dd454g3LeMdVn15oim2rlWPNcbgH5Jr1AJZI5k4Bsp70zbQ0lpNtg=="
},
"svelte-eslint-parser": {
"version": "0.32.2",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.32.2.tgz",

View File

@@ -38,6 +38,7 @@
"dependencies": {
"@zip.js/zip.js": "^2.7.20",
"dexie": "^4.0.1-alpha.25",
"panzoom": "^9.4.3"
"panzoom": "^9.4.3",
"svelte-easy-crop": "^2.0.1"
}
}

View File

@@ -0,0 +1,78 @@
import { showSnackbar } from '$lib/util';
import { writable } from 'svelte/store';
import { blobToBase64 } from '.';
type CropperModal = {
open: boolean;
image?: string;
sentence?: string;
};
export const cropperStore = writable<CropperModal | undefined>(undefined);
export function showCropper(image: string, sentence: string) {
cropperStore.set({
open: true,
image,
sentence
});
}
async function createImage(url: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
}
function getRadianAngle(degreeValue: number) {
return (degreeValue * Math.PI) / 180;
}
export type Pixels = { width: number; height: number; x: number; y: number }
export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, rotation = 0) {
const image = await createImage(imageSrc);
const canvas = new OffscreenCanvas(image.width, image.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
showSnackbar('Error: crop failed')
return;
}
const maxSize = Math.max(image.width, image.height);
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
// set each dimensions to double largest dimension to allow for a safe area for the
// image to rotate in without being clipped by canvas context
canvas.width = safeArea;
canvas.height = safeArea;
// translate canvas context to a central location on image to allow rotating around the center.
ctx.translate(safeArea / 2, safeArea / 2);
ctx.rotate(getRadianAngle(rotation));
ctx.translate(-safeArea / 2, -safeArea / 2);
// draw rotated image and store data.
ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
const data = ctx.getImageData(0, 0, safeArea, safeArea);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// paste generated rotate image with correct offsets for x,y crop values.
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
);
const blob = await canvas.convertToBlob({ type: 'image/webp' });
return await blobToBase64(blob)
}

View File

@@ -0,0 +1,118 @@
import { settings } from "$lib/settings";
import { showSnackbar } from "$lib/util"
import { get } from "svelte/store";
export * from './cropper'
export async function ankiConnect(action: string, params: Record<string, any>) {
try {
const res = await fetch('http://127.0.0.1:8765', {
method: 'POST',
body: JSON.stringify({ action, params, version: 6 })
})
const json = await res.json()
if (json.error) {
throw new Error(json.error)
}
return json.result;
} catch (e: any) {
showSnackbar(`Error: ${e?.message ?? e}`)
}
}
export async function getCardInfo(id: string) {
const [noteInfo] = await ankiConnect('notesInfo', { notes: [id] });
return noteInfo;
}
export async function getLastCardId() {
const notesToday = await ankiConnect('findNotes', { query: 'added:1' });
const id = notesToday.sort().at(-1);
return id
}
export async function getLastCardInfo() {
const id = await getLastCardId()
return await getCardInfo(id);
}
export function getCardAgeInMin(id: number) {
return Math.floor((Date.now() - id) / 60000);
}
export async function blobToBase64(blob: Blob) {
return new Promise<string | null>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}
export async function imageToWebp(source: File) {
const image = await createImageBitmap(source);
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d");
if (context) {
context.drawImage(image, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/webp' });
image.close();
return await blobToBase64(blob);
}
}
export async function updateLastCard(imageData: string | null | undefined, sentence: string) {
const {
overwriteImage,
enabled,
grabSentence,
pictureField,
sentenceField
} = get(settings).ankiConnectSettings;
if (!enabled) {
return
}
showSnackbar('Updating last card...', 10000)
const id = await getLastCardId()
if (getCardAgeInMin(id) >= 5) {
showSnackbar('Error: Card created over 5 minutes ago');
return;
}
const fields: Record<string, any> = {};
if (grabSentence) {
fields[sentenceField] = sentence;
}
if (overwriteImage) {
fields[pictureField] = ''
}
if (imageData) {
ankiConnect('updateNoteFields', {
note: {
id,
fields,
picture: {
filename: `_${id}.webp`,
data: imageData.split(';base64,')[1],
fields: [pictureField],
},
},
}).then(() => {
showSnackbar('Card updated!')
}).catch((e) => {
showSnackbar(e)
})
} else {
showSnackbar('Something went wrong')
}
}

View File

@@ -8,7 +8,9 @@
onMount(() => {
confirmationPopupStore.subscribe((value) => {
open = Boolean(value);
if (value) {
open = value.open;
}
});
});
</script>

View File

@@ -3,7 +3,7 @@
import { UserSettingsSolid, UploadSolid } from 'flowbite-svelte-icons';
import { afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import Settings from './Settings.svelte';
import Settings from './Settings/Settings.svelte';
import UploadModal from './UploadModal.svelte';
import { settings } from '$lib/settings';

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { cropperStore, getCroppedImg, updateLastCard, type Pixels } from '$lib/anki-connect';
import { Button, Modal, Spinner } from 'flowbite-svelte';
import { onMount } from 'svelte';
import Cropper from 'svelte-easy-crop';
let open = false;
let pixels: Pixels;
let loading = false;
afterNavigate(() => {
close();
});
onMount(() => {
cropperStore.subscribe((value) => {
if (value) {
open = value.open;
}
});
});
function close() {
loading = false;
cropperStore.set({ open: false });
}
async function onCrop() {
if ($cropperStore?.image && $cropperStore?.sentence && pixels) {
loading = true;
const imageData = await getCroppedImg($cropperStore.image, pixels);
updateLastCard(imageData, $cropperStore.sentence);
close();
}
}
function onCropComplete(e: any) {
pixels = e.detail.pixels;
}
</script>
<Modal title="Crop image" bind:open on:{close}>
{#if $cropperStore?.image && !loading}
<div class=" flex flex-col gap-2">
<div class="relative w-full h-[55svh] sm:h-[70svh]">
<Cropper
zoomSpeed={0.5}
maxZoom={10}
image={$cropperStore?.image}
on:cropcomplete={onCropComplete}
/>
</div>
<Button on:click={onCrop}>Crop</Button>
<Button on:click={close} outline color="light">Close</Button>
</div>
{:else}
<div class="text-center"><Spinner /></div>
{/if}
</Modal>

View File

@@ -3,7 +3,7 @@
import TextBoxes from './TextBoxes.svelte';
export let page: Page;
export let src: Blob;
export let src: File;
</script>
<div
@@ -13,5 +13,5 @@
style:background-image={`url(${URL.createObjectURL(src)})`}
class="relative"
>
<TextBoxes {page} />
<TextBoxes {page} {src} />
</div>

View File

@@ -7,6 +7,7 @@
import MangaPage from './MangaPage.svelte';
import { ChervonDoubleLeftSolid, ChervonDoubleRightSolid } from 'flowbite-svelte-icons';
import { afterUpdate } from 'svelte';
import Cropper from './Cropper.svelte';
const volume = $currentVolume;
const pages = volume?.mokuroData.pages;
@@ -94,6 +95,7 @@
<svelte:window on:resize={zoomDefault} />
{#if volume && pages}
<Cropper />
<Popover placement="bottom-end" trigger="click" triggeredBy="#page-num" class="z-20">
<div class="flex flex-col gap-3">
<div class="flex flex-row items-center gap-5 z-10">

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { clamp } from '$lib/util';
import { clamp, promptConfirmation } from '$lib/util';
import type { Page } from '$lib/types';
import { settings } from '$lib/settings';
import { imageToWebp, showCropper, updateLastCard } from '$lib/anki-connect';
export let page: Page;
export let src: File;
$: textBoxes = page.blocks.map((block) => {
const { img_height, img_width } = page;
@@ -36,10 +38,24 @@
$: display = $settings.displayOCR ? 'block' : 'none';
$: border = $settings.textBoxBorders ? '1px solid red' : 'none';
$: contenteditable = $settings.textEditable;
async function onUpdateCard(lines: string[]) {
if ($settings.ankiConnectSettings.enabled) {
const sentence = lines.join(' ');
if ($settings.ankiConnectSettings.cropImage) {
showCropper(URL.createObjectURL(src), sentence);
} else {
promptConfirmation('Add image to last created anki card?', async () => {
const imageData = await imageToWebp(src);
updateLastCard(imageData, sentence);
});
}
}
}
</script>
{#each textBoxes as { fontSize, height, left, lines, top, width, writingMode }, index (`text-box-${index}`)}
<div
<button
class="text-box"
style:width
style:height
@@ -50,12 +66,13 @@
style:font-weight={fontWeight}
style:display
style:border
on:dblclick={() => onUpdateCard(lines)}
{contenteditable}
>
{#each lines as line}
<p>{line}</p>
{/each}
</div>
</button>
{/each}
<style>
@@ -67,14 +84,12 @@
font-size: 16pt;
white-space: nowrap;
border: 1px solid rgba(0, 0, 0, 0);
z-index: 1000;
}
.text-box:focus,
.text-box:hover {
background: rgb(255, 255, 255);
border: 1px solid rgba(0, 0, 0, 0);
z-index: 999 !important;
}
.text-box p {

View File

@@ -1,113 +0,0 @@
<script lang="ts">
import { Drawer, CloseButton, Toggle, Select, Input, Label, Button } from 'flowbite-svelte';
import { UserSettingsSolid } from 'flowbite-svelte-icons';
import { sineIn } from 'svelte/easing';
import { resetSettings, settings, updateSetting } from '$lib/settings';
import type { SettingsKey } from '$lib/settings';
import { promptConfirmation } from '$lib/util';
import { zoomDefault } from '$lib/panzoom';
let transitionParams = {
x: 320,
duration: 200,
easing: sineIn
};
export let hidden = true;
$: zoomModeValue = $settings.zoomDefault;
$: fontSizeValue = $settings.fontSize;
let zoomModes = [
{ value: 'zoomFitToScreen', name: 'Fit to screen' },
{ value: 'zoomFitToWidth', name: 'Fit to width' },
{ value: 'zoomOriginal', name: 'Original size' },
{ value: 'keepZoom', name: 'Keep zoom' },
{ value: 'keepZoomStart', name: 'Keep zoom, pan to top' }
];
let fontSizes = [
{ value: 'auto', name: 'auto' },
{ value: '9', name: '9' },
{ value: '10', name: '10' },
{ value: '11', name: '11' },
{ value: '12', name: '12' },
{ value: '14', name: '14' },
{ value: '16', name: '16' },
{ value: '18', name: '18' },
{ value: '20', name: '20' },
{ value: '24', name: '24' },
{ value: '32', name: '32' },
{ value: '40', name: '40' },
{ value: '48', name: '48' },
{ value: '60', name: '60' }
];
$: toggles = [
{ key: 'rightToLeft', text: 'Right to left', value: $settings.rightToLeft },
{ key: 'singlePageView', text: 'Single page view', value: $settings.singlePageView },
{ key: 'hasCover', text: 'First page is cover', value: $settings.hasCover },
{ key: 'textEditable', text: 'Editable text', value: $settings.textEditable },
{ key: 'textBoxBorders', text: 'Text box borders', value: $settings.textBoxBorders },
{ key: 'displayOCR', text: 'OCR enabled', value: $settings.displayOCR },
{ key: 'boldFont', text: 'Bold font', value: $settings.boldFont },
{ key: 'pageNum', text: 'Show page number', value: $settings.pageNum }
] as { key: SettingsKey; text: string; value: any }[];
function onBackgroundColor(event: Event) {
updateSetting('backgroundColor', (event.target as HTMLInputElement).value);
}
function onSelectChange(event: Event, setting: SettingsKey) {
updateSetting(setting, (event.target as HTMLInputElement).value);
}
function onReset() {
hidden = true;
promptConfirmation('Restore default settings?', resetSettings);
}
</script>
<Drawer
placement="right"
transitionType="fly"
width="lg:w-1/4 md:w-1/2 w-full"
{transitionParams}
bind:hidden
id="settings"
>
<div class="flex items-center">
<h5 id="drawer-label" class="inline-flex items-center mb-4 text-base font-semibold">
<UserSettingsSolid class="w-4 h-4 mr-2.5" />Settings
</h5>
<CloseButton on:click={() => (hidden = true)} class="mb-4 dark:text-white" />
</div>
<div class="flex flex-col gap-5">
<div>
<Label>On page zoom:</Label>
<Select
items={zoomModes}
value={zoomModeValue}
on:change={(e) => onSelectChange(e, 'zoomDefault')}
/>
</div>
{#each toggles as { key, text, value }}
<Toggle size="small" checked={value} on:change={() => updateSetting(key, !value)}
>{text}</Toggle
>
{/each}
<div>
<Label>Fontsize:</Label>
<Select
items={fontSizes}
value={fontSizeValue}
on:change={(e) => onSelectChange(e, 'fontSize')}
/>
</div>
<div>
<Label>Background color:</Label>
<Input type="color" on:change={onBackgroundColor} value={$settings.backgroundColor} />
</div>
<Button outline on:click={onReset}>Reset</Button>
</div>
</Drawer>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { settings, updateAnkiSetting } from '$lib/settings';
import { AccordionItem, Label, Toggle, Input } from 'flowbite-svelte';
$: disabled = !$settings.ankiConnectSettings.enabled;
let enabled = $settings.ankiConnectSettings.enabled;
let cropImage = $settings.ankiConnectSettings.cropImage;
let grabSentence = $settings.ankiConnectSettings.grabSentence;
let overwriteImage = $settings.ankiConnectSettings.overwriteImage;
let pictureField = $settings.ankiConnectSettings.pictureField;
let sentenceField = $settings.ankiConnectSettings.sentenceField;
</script>
<AccordionItem>
<span slot="header">Anki Connect</span>
<div class="flex flex-col gap-5">
<div>
<Toggle bind:checked={enabled} on:change={() => updateAnkiSetting('enabled', enabled)}
>AnkiConnect Integration Enabled</Toggle
>
</div>
<div>
<Label>Picture field:</Label>
<Input
{disabled}
type="text"
bind:value={pictureField}
on:change={() => updateAnkiSetting('pictureField', pictureField)}
/>
</div>
<div>
<Label>Sentence field:</Label>
<Input
{disabled}
type="text"
bind:value={sentenceField}
on:change={() => updateAnkiSetting('sentenceField', sentenceField)}
/>
</div>
<div>
<Toggle
{disabled}
bind:checked={cropImage}
on:change={() => updateAnkiSetting('cropImage', cropImage)}>Crop image</Toggle
>
</div>
<div>
<Toggle
{disabled}
bind:checked={overwriteImage}
on:change={() => updateAnkiSetting('overwriteImage', overwriteImage)}
>Overwrite image</Toggle
>
</div>
<div>
<Toggle
{disabled}
bind:checked={grabSentence}
on:change={() => updateAnkiSetting('grabSentence', grabSentence)}>Grab sentence</Toggle
>
</div>
</div>
</AccordionItem>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { AccordionItem, Button, Label, Select } from 'flowbite-svelte';
let profiles = [
{ value: 'default', name: 'Default' },
{ value: 'profile1', name: 'Profile 1' },
{ value: 'profile2', name: 'Porfile 2' }
];
let profile = 'default';
</script>
<AccordionItem>
<span slot="header">Profile</span>
<div class="flex flex-col gap-2">
<Select items={profiles} value={profile} />
<Button size="sm" outline color="dark">Manage profiles</Button>
</div>
</AccordionItem>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { Select, Input, Label } from 'flowbite-svelte';
import { settings, updateSetting } from '$lib/settings';
import type { SettingsKey } from '$lib/settings';
$: zoomModeValue = $settings.zoomDefault;
$: fontSizeValue = $settings.fontSize;
let zoomModes = [
{ value: 'zoomFitToScreen', name: 'Fit to screen' },
{ value: 'zoomFitToWidth', name: 'Fit to width' },
{ value: 'zoomOriginal', name: 'Original size' },
{ value: 'keepZoom', name: 'Keep zoom' },
{ value: 'keepZoomStart', name: 'Keep zoom, pan to top' }
];
let fontSizes = [
{ value: 'auto', name: 'auto' },
{ value: '9', name: '9' },
{ value: '10', name: '10' },
{ value: '11', name: '11' },
{ value: '12', name: '12' },
{ value: '14', name: '14' },
{ value: '16', name: '16' },
{ value: '18', name: '18' },
{ value: '20', name: '20' },
{ value: '24', name: '24' },
{ value: '32', name: '32' },
{ value: '40', name: '40' },
{ value: '48', name: '48' },
{ value: '60', name: '60' }
];
function onBackgroundColor(event: Event) {
updateSetting('backgroundColor', (event.target as HTMLInputElement).value);
}
function onSelectChange(event: Event, setting: SettingsKey) {
updateSetting(setting, (event.target as HTMLInputElement).value);
}
</script>
<div>
<Label>On page zoom:</Label>
<Select
items={zoomModes}
value={zoomModeValue}
on:change={(e) => onSelectChange(e, 'zoomDefault')}
/>
</div>
<div>
<Label>Fontsize:</Label>
<Select
items={fontSizes}
value={fontSizeValue}
on:change={(e) => onSelectChange(e, 'fontSize')}
/>
</div>
<div>
<Label>Background color:</Label>
<Input type="color" on:change={onBackgroundColor} value={$settings.backgroundColor} />
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { AccordionItem } from 'flowbite-svelte';
import ReaderSelects from './ReaderSelects.svelte';
import ReaderToggles from './ReaderToggles.svelte';
import { page } from '$app/stores';
</script>
<AccordionItem open={$page.route.id === '/[manga]/[volume]'}>
<span slot="header">Reader</span>
<div class="flex flex-col gap-5">
<ReaderSelects />
<hr class="border-gray-100 opacity-10" />
<ReaderToggles />
</div>
</AccordionItem>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { settings, updateSetting, type SettingsKey } from '$lib/settings';
import { Toggle } from 'flowbite-svelte';
$: toggles = [
{ key: 'rightToLeft', text: 'Right to left', value: $settings.rightToLeft },
{ key: 'singlePageView', text: 'Single page view', value: $settings.singlePageView },
{ key: 'hasCover', text: 'First page is cover', value: $settings.hasCover },
{ key: 'textEditable', text: 'Editable text', value: $settings.textEditable },
{ key: 'textBoxBorders', text: 'Text box borders', value: $settings.textBoxBorders },
{ key: 'displayOCR', text: 'OCR enabled', value: $settings.displayOCR },
{ key: 'boldFont', text: 'Bold font', value: $settings.boldFont },
{ key: 'pageNum', text: 'Show page number', value: $settings.pageNum },
{ key: 'mobile', text: 'Mobile', value: $settings.mobile }
] as { key: SettingsKey; text: string; value: any }[];
</script>
{#each toggles as { key, text, value }}
<Toggle size="small" checked={value} on:change={() => updateSetting(key, !value)}>{text}</Toggle>
{/each}

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { Drawer, CloseButton, Button, Accordion } from 'flowbite-svelte';
import { UserSettingsSolid } from 'flowbite-svelte-icons';
import { sineIn } from 'svelte/easing';
import { resetSettings } from '$lib/settings';
import { promptConfirmation } from '$lib/util';
import AnkiConnectSettings from './AnkiConnectSettings.svelte';
import ReaderSettings from './ReaderSettings.svelte';
import Profiles from './Profiles.svelte';
let transitionParams = {
x: 320,
duration: 200,
easing: sineIn
};
export let hidden = true;
function onReset() {
hidden = true;
promptConfirmation('Restore default settings?', resetSettings);
}
function onClose() {
hidden = true;
}
</script>
<Drawer
placement="right"
transitionType="fly"
width="lg:w-1/4 md:w-1/2 w-full"
{transitionParams}
bind:hidden
id="settings"
>
<div class="flex items-center">
<h5 id="drawer-label" class="inline-flex items-center mb-4 text-base font-semibold">
<UserSettingsSolid class="w-4 h-4 mr-2.5" />Settings
</h5>
<CloseButton on:click={onClose} class="mb-4 dark:text-white" />
</div>
<div class="flex flex-col gap-5">
<Accordion flush>
<ReaderSettings />
<AnkiConnectSettings />
<Profiles />
</Accordion>
<div class="flex flex-col gap-2">
<Button outline on:click={onReset}>Reset</Button>
<Button outline on:click={onClose} color="light">Close</Button>
</div>
</div>
</Drawer>

View File

@@ -4,5 +4,5 @@
</script>
{#if $snackbarStore?.message && $snackbarStore?.visible}
<Toast position="bottom-right">{$snackbarStore?.message}</Toast>
<Toast position="bottom-right" class="z-50">{$snackbarStore?.message}</Toast>
{/if}

View File

@@ -29,10 +29,10 @@
draggedFiles = undefined;
}
let storageSpace = 'Loading...';
let storageSpace = '';
onMount(() => {
navigator.storage.estimate().then(({ usage, quota }) => {
navigator?.storage?.estimate().then(({ usage, quota }) => {
if (usage && quota) {
storageSpace = `Storage: ${formatBytes(usage)} / ${formatBytes(quota)}`;
}

View File

@@ -38,6 +38,10 @@ export function initPanzoom(node: HTMLElement) {
});
panzoomStore.set(pz);
pz.on('pan', () => keepInBounds())
pz.on('zoom', () => keepInBounds())
}
type PanX = 'left' | 'center' | 'right';
@@ -135,3 +139,56 @@ export function zoomDefault() {
return;
}
}
export function keepInBounds() {
if (!pz || !container) {
return
}
const { mobile } = get(settings)
if (!mobile) {
return
}
const transform = pz.getTransform();
const { x, y, scale } = transform;
const { innerWidth, innerHeight } = window
const width = container.offsetWidth * scale;
const height = container.offsetHeight * scale;
const marginX = innerWidth * 0.01;
const marginY = innerHeight * 0.01;
let minX = innerWidth - width - marginX;
let maxX = marginX;
let minY = innerHeight - height - marginY;
let maxY = marginY;
if (width + 2 * marginX <= innerWidth) {
minX = marginX;
maxX = innerWidth - width - marginX;
}
if (height + 2 * marginY <= innerHeight) {
minY = marginY;
maxY = innerHeight - height - marginY;
}
if (x < minX) {
transform.x = minX;
}
if (x > maxX) {
transform.x = maxX;
}
if (y < minY) {
transform.y = minY;
}
if (y > maxY) {
transform.y = maxY;
}
}

View File

@@ -34,25 +34,48 @@ export type Settings = {
boldFont: boolean;
pageNum: boolean;
hasCover: boolean;
mobile: boolean;
backgroundColor: string;
fontSize: FontSize;
zoomDefault: ZoomModes;
ankiConnectSettings: AnkiConnectSettings;
};
export type SettingsKey = keyof Settings;
export type AnkiConnectSettings = {
enabled: boolean;
pictureField: string;
sentenceField: string;
cropImage: boolean;
overwriteImage: boolean;
grabSentence: boolean;
}
export type AnkiSettingsKey = keyof AnkiConnectSettings;
const defaultSettings: Settings = {
rightToLeft: true,
singlePageView: true,
singlePageView: false,
hasCover: false,
displayOCR: true,
textEditable: false,
textBoxBorders: false,
boldFont: false,
pageNum: true,
mobile: false,
backgroundColor: '#0d0d0f',
fontSize: 'auto',
zoomDefault: 'zoomFitToScreen'
zoomDefault: 'zoomFitToScreen',
ankiConnectSettings: {
enabled: false,
cropImage: false,
grabSentence: false,
overwriteImage: true,
pictureField: 'Picture',
sentenceField: 'Sentence'
}
};
const stored = browser ? window.localStorage.getItem('settings') : undefined;
@@ -72,6 +95,18 @@ export function updateSetting(key: SettingsKey, value: any) {
zoomDefault();
}
export function updateAnkiSetting(key: AnkiSettingsKey, value: any) {
settings.update((settings) => {
return {
...settings,
ankiConnectSettings: {
...settings.ankiConnectSettings,
[key]: value
}
};
});
}
export function resetSettings() {
settings.set(defaultSettings);
}

View File

@@ -4,6 +4,10 @@ import { showSnackbar } from '$lib/util/snackbar';
import { requestPersistentStorage } from '$lib/util/upload';
import { BlobReader, ZipReader, BlobWriter, getMimeType } from '@zip.js/zip.js';
const zipTypes = ['zip', 'cbz', 'ZIP', 'CBZ'];
const imageTypes = ['image/jpeg', 'image/png', 'image/webp'];
export async function unzipManga(file: File) {
const zipFileReader = new BlobReader(file);
const zipReader = new ZipReader(zipFileReader);
@@ -13,7 +17,7 @@ export async function unzipManga(file: File) {
for (const entry of entries) {
const mime = getMimeType(entry.filename);
if (mime === 'image/jpeg' || mime === 'image/png') {
if (imageTypes.includes(mime)) {
const blob = await entry.getData?.(new BlobWriter(mime));
if (blob) {
const file = new File([blob], entry.filename, { type: mime });
@@ -61,7 +65,6 @@ export async function scanFiles(item: FileSystemEntry, files: Promise<File | und
}
export async function processFiles(files: File[]) {
const zipTypes = ['zip', 'cbz'];
const volumes: Record<string, Volume> = {};
const mangas: string[] = [];
@@ -86,7 +89,7 @@ export async function processFiles(files: File[]) {
const mimeType = type || getMimeType(file.name);
if (mimeType === 'image/jpeg' || mimeType === 'image/png') {
if (imageTypes.includes(mimeType)) {
if (webkitRelativePath) {
const imageName = webkitRelativePath.split('/').at(-1);
const vol = webkitRelativePath.split('/').at(-2);