mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-05 01:22:50 +00:00
layout sharing via url
[deploy]
This commit is contained in:
@@ -2,6 +2,7 @@ import {writable} from "svelte/store"
|
||||
import {CharaDevice} from "$lib/serial/device"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import type {Writable} from "svelte/store"
|
||||
import type {CharaLayout} from "$lib/serialization/layout"
|
||||
|
||||
export const serialPort = writable<CharaDevice>()
|
||||
|
||||
@@ -14,8 +15,6 @@ export const serialLog = writable<SerialLogEntry[]>([])
|
||||
|
||||
export const chords = writable<Chord[]>([])
|
||||
|
||||
export type CharaLayout = [number[], number[], number[]]
|
||||
|
||||
export const layout = writable<CharaLayout>([[], [], []])
|
||||
|
||||
export const unsavedChanges = writable(0)
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function stringifyCompressed(chords: any): Promise<Blob> {
|
||||
* Decompress JSON.parse with gzip
|
||||
*/
|
||||
export async function parseCompressed<T>(blob: Blob): Promise<T> {
|
||||
const stream = blob.stream().pipeThrough(new DecompressionStream("gzip"))
|
||||
const stream = blob.stream().pipeThrough(new DecompressionStream("deflate"))
|
||||
return await new Response(stream).json()
|
||||
}
|
||||
|
||||
|
||||
28
src/lib/serialization/actions.spec.ts
Normal file
28
src/lib/serialization/actions.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {describe, it, expect} from "vitest"
|
||||
import {compressActions, decompressActions} from "./actions"
|
||||
|
||||
describe("layout", function () {
|
||||
const actions = [1, 5, 2, 1023, 42, 2, 4, 78]
|
||||
|
||||
describe("compression", function () {
|
||||
it("should compress back and forth arrays divisible by 4", function () {
|
||||
expect(decompressActions(compressActions(actions))).toEqual(actions)
|
||||
})
|
||||
|
||||
it("should compress back and forth arrays divisible not divisible by 4", function () {
|
||||
expect(decompressActions(compressActions([...actions, 1023, 512, 123]))).toEqual([
|
||||
...actions,
|
||||
1023,
|
||||
512,
|
||||
123,
|
||||
])
|
||||
expect(decompressActions(compressActions([...actions, 1023, 512]))).toEqual([...actions, 1023, 512])
|
||||
expect(decompressActions(compressActions([...actions, 1023]))).toEqual([...actions, 1023])
|
||||
})
|
||||
|
||||
it("should compress alternating 0/1023", function () {
|
||||
const array = Array.from({length: 128}).map((_, i) => (i % 2 === 0 ? 0 : 1023))
|
||||
expect(decompressActions(compressActions(array))).toEqual(array)
|
||||
})
|
||||
})
|
||||
})
|
||||
38
src/lib/serialization/actions.ts
Normal file
38
src/lib/serialization/actions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Compresses an action list into a Uint8Array of 10-bit integers, supporting values of up to 1023
|
||||
*/
|
||||
export function compressActions(actions: number[]): Uint8Array {
|
||||
const overflow = actions.length % 4
|
||||
const array = new Uint8Array(
|
||||
Math.ceil((actions.length - overflow) * 1.25 + (overflow === 0 ? 0 : overflow + 1)),
|
||||
)
|
||||
let arrayOffset = 0
|
||||
for (let i = 0; i < actions.length; i += 4) {
|
||||
let final = 0
|
||||
for (let j = 0; j < 4 && i + j < actions.length; j++) {
|
||||
const action = actions[i + j]
|
||||
array[arrayOffset++] = (action >>> 2) & 0xff
|
||||
final |= (action & 0x03) << (j * 2)
|
||||
}
|
||||
array[arrayOffset++] = final
|
||||
}
|
||||
console.assert(arrayOffset === array.length)
|
||||
return array
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompresses actions
|
||||
*
|
||||
* @see {compressActions}
|
||||
*/
|
||||
export function decompressActions(raw: Uint8Array): number[] {
|
||||
const actions: number[] = []
|
||||
for (let i = 0; i < raw.length + 4; i += 5) {
|
||||
const overflow = raw[Math.min(i + 4, raw.length - 1)]
|
||||
|
||||
for (let j = 0; j < 4 && i + j < raw.length - 1; j++) {
|
||||
actions.push((raw[i + j] << 2) | ((overflow >>> (j * 2)) & 0x3))
|
||||
}
|
||||
}
|
||||
return actions
|
||||
}
|
||||
12
src/lib/serialization/base64.spec.ts
Normal file
12
src/lib/serialization/base64.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {describe, it, expect} from "vitest"
|
||||
import {fromBase64, toBase64} from "./base64"
|
||||
|
||||
describe("base64", function () {
|
||||
const data = new Uint8Array([24, 235, 22, 67, 84, 73, 23, 77, 21])
|
||||
|
||||
it("should convert back-forth", async function () {
|
||||
expect(await fromBase64(await toBase64(new Blob([data]))).then(it => it.arrayBuffer())).toEqual(
|
||||
data.buffer,
|
||||
)
|
||||
})
|
||||
})
|
||||
30
src/lib/serialization/base64.ts
Normal file
30
src/lib/serialization/base64.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Encodes a gzipped binary blob to a base64 string.
|
||||
*
|
||||
* Note that the string is url-compatible base64,
|
||||
* meaning some chars are swapped for compatibility
|
||||
*/
|
||||
export async function toBase64(blob: Blob): Promise<string> {
|
||||
return new Promise(async resolve => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = function () {
|
||||
resolve(
|
||||
(reader.result as string)
|
||||
.replace(/^data:application\/octet-stream;base64,/, "")
|
||||
.replaceAll("+", ".")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "-"),
|
||||
)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export async function fromBase64(base64: string): Promise<Blob> {
|
||||
return fetch(
|
||||
`data:application/octet-stream;base64,${base64
|
||||
.replaceAll(".", "+")
|
||||
.replaceAll("_", "/")
|
||||
.replaceAll("-", "=")}`,
|
||||
).then(it => it.blob())
|
||||
}
|
||||
21
src/lib/serialization/layout.sample.json
Normal file
21
src/lib/serialization/layout.sample.json
Normal file
@@ -0,0 +1,21 @@
|
||||
[
|
||||
[
|
||||
600, 47, 45, 515, 297, 601, 119, 562, 103, 122, 602, 107, 118, 109, 99, 603, 114, 298, 32, 101, 604, 105,
|
||||
127, 46, 111, 605, 39, 512, 44, 117, 552, 513, 514, 550, 540, 607, 335, 338, 336, 337, 608, 565, 568, 566,
|
||||
567, 609, 563, 63, 519, 297, 610, 98, 120, 536, 113, 611, 102, 112, 104, 100, 612, 97, 296, 544, 116, 613,
|
||||
108, 299, 106, 110, 614, 121, 516, 59, 115, 553, 517, 518, 551, 542, 616, 336, 338, 335, 337, 617, 566,
|
||||
568, 565, 567
|
||||
],
|
||||
[
|
||||
0, 92, 45, 515, 297, 0, 119, 562, 91, 93, 0, 55, 56, 57, 48, 0, 49, 298, 51, 50, 0, 52, 127, 54, 53, 0,
|
||||
96, 512, 61, 124, 0, 513, 514, 550, 540, 0, 569, 572, 570, 571, 0, 565, 568, 566, 567, 0, 563, 63, 519,
|
||||
297, 0, 98, 120, 91, 93, 0, 55, 56, 57, 48, 0, 49, 296, 51, 50, 0, 52, 299, 54, 53, 0, 61, 516, 59, 115,
|
||||
0, 517, 518, 551, 542, 0, 570, 572, 569, 571, 0, 566, 568, 565, 567
|
||||
],
|
||||
[
|
||||
0, 47, 45, 515, 297, 0, 119, 324, 325, 122, 0, 320, 321, 322, 323, 0, 314, 298, 316, 315, 0, 317, 127,
|
||||
319, 318, 0, 39, 512, 44, 117, 552, 513, 514, 0, 540, 0, 335, 338, 336, 337, 0, 569, 572, 570, 571, 0,
|
||||
563, 63, 519, 297, 0, 98, 324, 325, 113, 0, 320, 321, 322, 323, 0, 314, 296, 316, 315, 0, 317, 299, 319,
|
||||
318, 0, 121, 516, 59, 115, 553, 517, 518, 0, 542, 0, 336, 338, 335, 337, 0, 570, 572, 569, 571
|
||||
]
|
||||
]
|
||||
28
src/lib/serialization/layout.ts
Normal file
28
src/lib/serialization/layout.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {compressActions, decompressActions} from "./actions"
|
||||
import {fromBase64, toBase64} from "$lib/serialization/base64"
|
||||
|
||||
export type CharaLayout = [number[], number[], number[]]
|
||||
|
||||
/**
|
||||
* Serialize a layout into a micro package
|
||||
*/
|
||||
export async function serializeLayout(layout: CharaLayout): Promise<Blob> {
|
||||
const items = compressActions(layout.flat())
|
||||
const stream = new Blob([items]).stream().pipeThrough(new CompressionStream("deflate"))
|
||||
return new Response(stream).blob()
|
||||
}
|
||||
|
||||
export async function deserializeLayout(layout: Blob): Promise<CharaLayout> {
|
||||
const stream = layout.stream().pipeThrough(new DecompressionStream("deflate"))
|
||||
const raw = await new Response(stream).arrayBuffer()
|
||||
const actions = decompressActions(new Uint8Array(raw))
|
||||
return [actions.slice(0, 90), actions.slice(90, 180), actions.slice(180, 270)]
|
||||
}
|
||||
|
||||
export async function layoutAsUrlComponent(layout: CharaLayout): Promise<string> {
|
||||
return serializeLayout(layout).then(toBase64)
|
||||
}
|
||||
|
||||
export async function layoutFromUrlComponent(base64: string): Promise<CharaLayout> {
|
||||
return fromBase64(base64).then(deserializeLayout)
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts">
|
||||
import LayoutCC1 from "$lib/components/LayoutCC1.svelte"
|
||||
import {share} from "$lib/share"
|
||||
import {getSharableUrl, parseSharableUrl} from "$lib/serial/serialization"
|
||||
import {layout} from "$lib/serial/connection"
|
||||
import type {CharaLayout} from "$lib/serial/connection"
|
||||
import tippy from "tippy.js"
|
||||
import {onMount} from "svelte"
|
||||
import {layoutAsUrlComponent, layoutFromUrlComponent} from "$lib/serialization/layout"
|
||||
|
||||
onMount(async () => {
|
||||
const sharedLayout = await parseSharableUrl<CharaLayout>("layout")
|
||||
if (sharedLayout) {
|
||||
$layout = sharedLayout
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.has("layout")) {
|
||||
$layout = await layoutFromUrlComponent(url.searchParams.get("layout")!)
|
||||
}
|
||||
})
|
||||
|
||||
async function shareLayout(event) {
|
||||
const data = await getSharableUrl("layout", $layout)
|
||||
await navigator.clipboard.writeText(data.toString())
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("layout", await layoutAsUrlComponent($layout))
|
||||
await navigator.clipboard.writeText(url.toString())
|
||||
tippy(event.target, {
|
||||
content: "Share url copied!",
|
||||
hideOnClick: true,
|
||||
|
||||
Reference in New Issue
Block a user