Compare commits
7 commits
e66c6f49db
...
1943c829f1
Author | SHA1 | Date | |
---|---|---|---|
1943c829f1 | |||
5b4d9ffa51 | |||
ad66044607 | |||
c0e1af2ad0 | |||
fb943e43df | |||
feefdc7a1e | |||
3264f19021 |
14 changed files with 356 additions and 58 deletions
|
@ -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
137
src/components/Modal.svelte
Normal 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>
|
67
src/components/PagePicker.svelte
Normal file
67
src/components/PagePicker.svelte
Normal 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>
|
|
@ -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%;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
29
src/lib/lastUpdated.ts
Normal 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
29
src/lib/makeYdocStore.ts
Normal 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
37
src/lib/router.ts
Normal 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();
|
|
@ -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();
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue