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 SimpleMarkItem from "./bubblemenu/SimpleMarkItem.svelte";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import Button from "./bubblemenu/Button.svelte";
|
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 view: EditorView;
|
||||||
export let state: EditorState;
|
export let state: EditorState;
|
||||||
|
export let worldY: WorldY;
|
||||||
|
|
||||||
let changingProp:
|
let changingProp:
|
||||||
| false
|
| false
|
||||||
|
@ -83,11 +87,17 @@
|
||||||
runCommand(updateMark(view.state.schema.marks.link, { href: url }));
|
runCommand(updateMark(view.state.schema.marks.link, { href: url }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInternalLink() {
|
let makingInternalLink = false;
|
||||||
const pageId = nanoid();
|
function startMakingInternalLink() {
|
||||||
runCommand(
|
if (markIsActive(state, view.state.schema.marks.internal_link)) {
|
||||||
updateMark(view.state.schema.marks.internal_link, { id: pageId })
|
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%";
|
const svgStyle = "width: 100%; height: 100%";
|
||||||
|
@ -117,6 +127,13 @@ transform: scale(${1 / viewport.scale});
|
||||||
});
|
});
|
||||||
</script>
|
</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}>
|
<div class="bubble" hidden={state.selection.empty} style={barStyle}>
|
||||||
{#if changingProp === false}
|
{#if changingProp === false}
|
||||||
<SimpleMarkItem {view} {state} type={view.state.schema.marks.strong}
|
<SimpleMarkItem {view} {state} type={view.state.schema.marks.strong}
|
||||||
|
@ -137,7 +154,8 @@ transform: scale(${1 / viewport.scale});
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
active={markIsActive(state, view.state.schema.marks.internal_link)}
|
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"}
|
{:else if changingProp.type === "link"}
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -72,7 +72,7 @@
|
||||||
<!-- this element gets replaced with the editor itself when mounted -->
|
<!-- this element gets replaced with the editor itself when mounted -->
|
||||||
<div bind:this={wrapperEl} />
|
<div bind:this={wrapperEl} />
|
||||||
{#if view}
|
{#if view}
|
||||||
<BubbleMenu {view} state={updatedState} />
|
<BubbleMenu {view} {worldY} state={updatedState} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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.");
|
throw new Error("No conozco ese mundo.");
|
||||||
}
|
}
|
||||||
const worldY = getWorldY(worldIdentifier);
|
const worldY = getWorldY(worldIdentifier);
|
||||||
|
|
||||||
return { worldY, doc: getWorldPage(worldY.ydoc, pageId) };
|
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>
|
</script>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
|
@ -38,12 +47,13 @@
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</nav>
|
</nav>
|
||||||
{#await docPromise then doc}
|
|
||||||
<Editor doc={doc.doc} worldY={doc.worldY} />
|
{#if state === "loading"}Cargando...{:else if "doc" in state}
|
||||||
{:catch error}
|
<Editor doc={state.doc} worldY={state.worldY} />
|
||||||
{error}
|
{:else if "error" in state}
|
||||||
|
{state.error}
|
||||||
<a href={routes.ChooseWorld}>Volver al inicio</a>
|
<a href={routes.ChooseWorld}>Volver al inicio</a>
|
||||||
{/await}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
nav a {
|
nav a {
|
||||||
|
|
Loading…
Reference in a new issue