Compare commits

...

7 commits

14 changed files with 356 additions and 58 deletions

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { currentRoute } from "./lib/routes";
import { currentRoute } from "./lib/router";
</script>
<main>

137
src/components/Modal.svelte Normal file
View file

@ -0,0 +1,137 @@
<script lang="ts">
export let onClose: () => void;
function click(event: Event) {
if (event.target !== this) return;
onClose();
}
function keydown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
</script>
<div
class="modal"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
on:click={click}
on:keydown={keydown}
>
<div class="backdrop" />
<div class="content-alignment" on:click={click} on:keydown={keydown}>
<div class="content">
<h3 id="modal-title">
<slot name="title" />
</h3>
<slot />
<!--<div
class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
--!>
<div
class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3
class="text-base font-semibold leading-6 text-gray-900"
id="modal-title"
>
Deactivate account
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Are you sure you want to deactivate your account? All of your
data will be permanently removed. This action cannot be
undone.
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button
type="button"
class="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"
>Deactivate</button
>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
>Cancel</button
>
</div>
</div>
</div>-->
</div>
</div>
</div>
<style>
.modal {
position: relative;
z-index: 169;
}
.backdrop {
position: fixed;
inset: 0;
background: #6b7280;
opacity: 0.75;
}
.content-alignment {
display: flex;
min-height: 100vh;
position: fixed;
inset: 0;
z-index: 269;
align-items: center;
justify-content: center;
overflow-y: auto;
}
.content {
overflow-y: none;
background: white;
padding: 16px 20px;
border-radius: 16px;
min-width: 50%;
min-height: 50%;
}
h3 {
margin: 0;
margin-bottom: 12px;
}
</style>

View file

@ -0,0 +1,67 @@
<script lang="ts">
import type { Readable } from "svelte/store";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import { Node } from "prosemirror-model";
import type { Doc } from "yjs";
import { schema } from "../editor/schema";
import { makeYdocStore } from "../lib/makeYdocStore";
import { lastUpdated } from "../lib/lastUpdated";
import { nanoid } from "nanoid";
type Entry = {
id: string;
name: string;
title?: string;
};
function getDocs(ydoc: Doc): Entry[] {
let docs: Entry[] = [];
for (const name of ydoc.share.keys()) {
if (name.startsWith("page/")) {
const json = yDocToProsemirrorJSON(ydoc, name);
const node = Node.fromJSON(schema, json);
let titleNode: null | Node = null;
node.descendants((node) => {
if (titleNode) return false;
if (node.type.name === "heading" && node.attrs.level === 1) {
titleNode = node;
return false;
}
});
docs.push({
title: titleNode?.textContent,
id: name.replace(/^page\//, ""),
name,
});
}
}
return docs;
}
let filter: string = "";
export let onChoose: (id: string) => void;
export let ydoc: Doc;
$: entries = deriveEntries(ydoc);
$: lastU = lastUpdated(ydoc);
$: sortedEntries = $entries
.sort((a, b) => +$lastU.get(b.name) - +$lastU.get(a.name))
// TODO: FTS
.filter((x) => (x.title ?? x.id).includes(filter));
// $: console.debug($lastU);
const deriveEntries = makeYdocStore((_, __, ydoc) => getDocs(ydoc));
</script>
<input type="text" bind:value={filter} placeholder="Buscar..." autofocus />
<ul>
<button type="button" on:click={() => onChoose(nanoid())}>Página nueva</button
>
{#each sortedEntries as entry}
<li>
<button type="button" on:click={() => onChoose(entry.id)}
>{entry.title ?? entry.id}</button
>
</li>
{/each}
</ul>

View file

@ -21,9 +21,13 @@
import SimpleMarkItem from "./bubblemenu/SimpleMarkItem.svelte";
import { nanoid } from "nanoid";
import Button from "./bubblemenu/Button.svelte";
import Modal from "../components/Modal.svelte";
import PagePicker from "../components/PagePicker.svelte";
import type { WorldY } from "../lib/doc";
export let view: EditorView;
export let state: EditorState;
export let worldY: WorldY;
let changingProp:
| false
@ -83,11 +87,17 @@
runCommand(updateMark(view.state.schema.marks.link, { href: url }));
}
function createInternalLink() {
const pageId = nanoid();
runCommand(
updateMark(view.state.schema.marks.internal_link, { id: pageId })
);
let makingInternalLink = false;
function startMakingInternalLink() {
if (markIsActive(state, view.state.schema.marks.internal_link)) {
runCommand(removeMark(view.state.schema.marks.internal_link));
} else {
makingInternalLink = true;
}
}
function makeInternalLink(id: string) {
runCommand(updateMark(view.state.schema.marks.internal_link, { id }));
makingInternalLink = false;
}
const svgStyle = "width: 100%; height: 100%";
@ -117,6 +127,13 @@ transform: scale(${1 / viewport.scale});
});
</script>
{#if makingInternalLink}
<Modal onClose={() => (makingInternalLink = false)}>
<svelte:fragment slot="title">Elegir página</svelte:fragment>
<PagePicker ydoc={worldY.ydoc} onChoose={makeInternalLink} />
</Modal>
{/if}
<div class="bubble" hidden={state.selection.empty} style={barStyle}>
{#if changingProp === false}
<SimpleMarkItem {view} {state} type={view.state.schema.marks.strong}
@ -137,7 +154,8 @@ transform: scale(${1 / viewport.scale});
>
<Button
active={markIsActive(state, view.state.schema.marks.internal_link)}
onClick={createInternalLink}><InternalLinkIcon style={svgStyle} /></Button
onClick={startMakingInternalLink}
><InternalLinkIcon style={svgStyle} /></Button
>
{:else if changingProp.type === "link"}
<input
@ -163,6 +181,7 @@ transform: scale(${1 / viewport.scale});
/* https://wicg.github.io/visual-viewport/examples/fixed-to-keyboard.html */
transform-origin: left bottom;
background: white;
border-top: 1px solid #ccc;
width: 100%;

View file

@ -72,7 +72,7 @@
<!-- this element gets replaced with the editor itself when mounted -->
<div bind:this={wrapperEl} />
{#if view}
<BubbleMenu {view} state={updatedState} />
<BubbleMenu {view} {worldY} state={updatedState} />
{/if}
</div>

View file

@ -1,5 +1,5 @@
import { Schema, type Attrs } from "prosemirror-model";
import { parse, inject } from "regexparam";
import { parse } from "regexparam";
import { routes } from "../lib/routes";
const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2);

View file

@ -31,9 +31,10 @@ export function getWorldY(world: WorldIdentifier): WorldY {
password: world.password,
signaling: [
"wss://webrtc-signaling.schreiben.nulo.ar",
"wss://signaling.yjs.dev",
"wss://y-webrtc-signaling-eu.herokuapp.com",
"wss://y-webrtc-signaling-us.herokuapp.com",
"wss://y-webrtc-eu.fly.dev",
// "wss://signaling.yjs.dev",
// "wss://y-webrtc-signaling-eu.herokuapp.com",
// "wss://y-webrtc-signaling-us.herokuapp.com",
],
peerOpts: {
config: {

29
src/lib/lastUpdated.ts Normal file
View file

@ -0,0 +1,29 @@
import type { AbstractType, Doc } from "yjs";
import { makeYdocStore } from "./makeYdocStore.js";
// XXX: si hay problemas de perf, mirar acá..
// después de implementar esto me di cuenta que simplemente puedo fijarme en
// cuales fueron las últimas páginas a las que se entró, y probablemente sería más útil.
export function lastUpdated(ydoc: Doc) {
let map: Map<string, Date> = new Map();
let observers: Set<{ y: AbstractType<any>; observer: () => void }> =
new Set();
return makeYdocStore(
(_, __, ydoc) => {
for (const name of ydoc.share.keys()) {
if (name.startsWith("page/")) {
if (!map.get(name)) {
map.set(name, new Date());
const y = ydoc.getXmlFragment(name);
const observer = () => map.set(name, new Date());
observers.add({ y, observer });
y.observeDeep(observer);
}
}
}
return map;
},
() => observers.forEach(({ y, observer }) => y.unobserveDeep(observer))
)(ydoc);
}

29
src/lib/makeYdocStore.ts Normal file
View file

@ -0,0 +1,29 @@
import type { Readable } from "svelte/store";
import type { Doc, Transaction } from "yjs";
export function makeYdocStore<T>(
handler: (update: Uint8Array, origin: any, ydoc: Doc, tr: Transaction) => T,
unhandler?: () => void
) {
return (ydoc: Doc): Readable<T> => {
// thanks https://github.com/relm-us/svelt-yjs/blob/main/src/types/array.ts
return {
subscribe: (run) => {
function updateHandler(
update: Uint8Array,
origin: any,
ydoc: Doc,
tr: Transaction
) {
run(handler(update, origin, ydoc, tr));
}
ydoc.on("update", updateHandler);
updateHandler(null, null, ydoc, null);
return () => {
if (unhandler) unhandler();
ydoc.off("update", updateHandler);
};
},
};
};
}

37
src/lib/router.ts Normal file
View file

@ -0,0 +1,37 @@
import navaid from "navaid";
import { writable } from "svelte/store";
import ChooseWorld from "../views/ChooseWorld.svelte";
import CreateWorld from "../views/CreateWorld.svelte";
import JoinWorld from "../views/JoinWorld.svelte";
import NotFound from "../views/NotFound.svelte";
import Page from "../views/Page.svelte";
import ShareWorld from "../views/ShareWorld.svelte";
import { routes } from "./routes";
export let currentRoute = writable<{
// XXX: in lack of a better type for Svelte components
component: any;
params?: Record<string, string>;
}>({ component: ChooseWorld });
export let router = navaid("/", () =>
currentRoute.set({ component: NotFound })
);
router.on(routes.ChooseWorld, () =>
currentRoute.set({ component: ChooseWorld })
);
router.on(routes.CreateWorld, () =>
currentRoute.set({ component: CreateWorld })
);
router.on(routes.ShareWorld, (params) =>
currentRoute.set({ component: ShareWorld, params })
);
router.on(routes.JoinWorld, (params) =>
currentRoute.set({ component: JoinWorld, params })
);
router.on(routes.Page, (params) =>
currentRoute.set({ component: Page, params })
);
router.listen();

View file

@ -1,22 +1,3 @@
import navaid from "navaid";
import { writable } from "svelte/store";
import ChooseWorld from "../views/ChooseWorld.svelte";
import CreateWorld from "../views/CreateWorld.svelte";
import JoinWorld from "../views/JoinWorld.svelte";
import NotFound from "../views/NotFound.svelte";
import Page from "../views/Page.svelte";
import ShareWorld from "../views/ShareWorld.svelte";
export let router = navaid("/", () =>
currentRoute.set({ component: NotFound })
);
export let currentRoute = writable<{
// XXX: in lack of a better type for Svelte components
component: any;
params?: Record<string, string>;
}>({ component: ChooseWorld });
export const routes = {
ChooseWorld: "/",
CreateWorld: "/create",
@ -24,21 +5,3 @@ export const routes = {
JoinWorld: "/w/:worldId/join", // password as hash
Page: "/w/:worldId/:pageId",
};
router.on(routes.ChooseWorld, () =>
currentRoute.set({ component: ChooseWorld })
);
router.on(routes.CreateWorld, () =>
currentRoute.set({ component: CreateWorld })
);
router.on(routes.ShareWorld, (params) =>
currentRoute.set({ component: ShareWorld, params })
);
router.on(routes.JoinWorld, (params) =>
currentRoute.set({ component: JoinWorld, params })
);
router.on(routes.Page, (params) =>
currentRoute.set({ component: Page, params })
);
router.listen();

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { generateNewWorld } from "../lib/doc";
import { router, routes } from "../lib/routes";
import { routes } from "../lib/routes";
import { router } from "../lib/router";
import { writeWorlds } from "../lib/worldStorage";
function crear(

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { router, routes } from "../lib/routes";
import { routes } from "../lib/routes";
import { router } from "../lib/router";
import { inject } from "regexparam";
import { writeWorlds } from "../lib/worldStorage";
@ -7,7 +8,11 @@
async function addWorld() {
const password = location.hash.slice(1);
await writeWorlds((worlds) => [...worlds, { room: worldId, password }]);
await writeWorlds((worlds) => [
// reemplazar mundo en vez de agregar nuevo, por si se agregó antes con la contraseña incorrecta
...worlds.filter(({ room }) => room !== worldId),
{ room: worldId, password },
]);
router.route(inject(routes.Page, { worldId, pageId: "index" }));
}
</script>

View file

@ -19,10 +19,19 @@
throw new Error("No conozco ese mundo.");
}
const worldY = getWorldY(worldIdentifier);
return { worldY, doc: getWorldPage(worldY.ydoc, pageId) };
}
$: docPromise = loadDoc(worldId, pageId);
let state: "loading" | { worldY: WorldY; doc: XmlFragment } | { error: any };
$: {
state = "loading";
loadDoc(worldId, pageId)
.then((doc) => {
state = doc;
})
.catch((error) => (state = { error }));
}
</script>
<nav>
@ -38,12 +47,13 @@
</ul>
</details>
</nav>
{#await docPromise then doc}
<Editor doc={doc.doc} worldY={doc.worldY} />
{:catch error}
{error}
{#if state === "loading"}Cargando...{:else if "doc" in state}
<Editor doc={state.doc} worldY={state.worldY} />
{:else if "error" in state}
{state.error}
<a href={routes.ChooseWorld}>Volver al inicio</a>
{/await}
{/if}
<style>
nav a {