layout sharing via url

[deploy]
This commit is contained in:
2023-07-09 01:20:38 +02:00
parent 391c9d8837
commit 26a6f70ccb
9 changed files with 166 additions and 10 deletions

View File

@@ -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)

View File

@@ -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()
}

View 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)
})
})
})

View 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
}

View 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,
)
})
})

View 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())
}

View 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
]
]

View 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)
}

View File

@@ -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,