poder elegir a que página linkear
This commit is contained in:
parent
c0e1af2ad0
commit
ad66044607
7 changed files with 300 additions and 13 deletions
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>
|
64
src/components/PagePicker.svelte
Normal file
64
src/components/PagePicker.svelte
Normal file
|
@ -0,0 +1,64 @@
|
|||
<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";
|
||||
|
||||
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>
|
||||
{#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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
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);
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
|
@ -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