This commit is contained in:
Cat /dev/Nulo 2023-03-05 17:10:29 +00:00
commit 007685f62a
41 changed files with 3247 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# schreiben
[WIP] Una aplicación web para escribir un mundo de cosas [knowledge base] en varios dispositivos. Sale de la frustración de que Notion funcione tan mal en Android (y iOS) y que sea software privativo en la nube.
El editor (src/editor) está basado en [sutty/editor](https://0xacab.org/sutty/editor).

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Schreiben</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "schreiben",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.2",
"@tsconfig/svelte": "^3.0.0",
"@vitejs/plugin-basic-ssl": "^1.0.1",
"prosemirror-commands": "~1.3.0",
"prosemirror-dropcursor": "~1.6.0",
"prosemirror-gapcursor": "~1.3.0",
"prosemirror-keymap": "~1.2.0",
"prosemirror-markdown": "~1.10.0",
"prosemirror-model": "~1.18.0",
"prosemirror-schema-basic": "~1.2.0",
"prosemirror-schema-list": "~1.2.0",
"prosemirror-state": "~1.4.0",
"prosemirror-transform": "~1.7.0",
"prosemirror-view": "~1.29.0",
"svelte": "^3.55.1",
"svelte-check": "^2.10.3",
"tslib": "^2.5.0",
"typescript": "^4.9.3",
"vite": "^4.1.0"
},
"dependencies": {
"bootstrap-icons": "^1.10.3",
"nanoid": "^4.0.1",
"navaid": "^1.2.0",
"y-prosemirror": "^1.2.0",
"y-protocols": "^1.0.5",
"y-webrtc": "^10.2.4",
"yjs": "^13.5.48"
}
}

1235
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

43
src/App.svelte Normal file
View File

@ -0,0 +1,43 @@
<script lang="ts">
import { nanoid } from "nanoid";
import Editor from "./editor/Editor.svelte";
import { getWorldPage, getWorldY } from "./lib/doc";
import { currentRoute } from "./lib/routes";
let worldDescriptor: string = "";
let fileId: string = nanoid();
$: world = worldDescriptor.includes(":")
? {
room: worldDescriptor.split(":")[0],
password: worldDescriptor.split(":")[1],
}
: null;
$: worldY = world && getWorldY(world);
$: doc = worldY && getWorldPage(worldY.ydoc, fileId);
function generateWorld() {
worldDescriptor = `${nanoid()}:${nanoid()}`;
}
</script>
<main>
<svelte:component this={$currentRoute.component} {...$currentRoute.params} />
<!-- <button on:click={generateWorld}>generar mundo</button>
<input
type="text"
bind:value={worldDescriptor}
placeholder="mundo descriptor"
/>
<input type="text" bind:value={fileId} placeholder="world" />
{#if doc}<Editor {doc} />{/if} -->
</main>
<style>
main {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
}
</style>

15
src/app.css Normal file
View File

@ -0,0 +1,15 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}

26
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
// https://www.npmjs.com/package/@poppanator/sveltekit-svg
declare module "*.svg" {
import type { ComponentType, SvelteComponentTyped } from "svelte";
import type { SVGAttributes } from "svelte/elements";
const content: ComponentType<
SvelteComponentTyped<SVGAttributes<SVGSVGElement>>
>;
export default content;
}
declare module "*.svg?src" {
const content: string;
export default content;
}
declare module "*.svg?url" {
const content: string;
export default content;
}
declare module "*.svg?dataurl" {
const content: string;
export default content;
}

View File

@ -0,0 +1,163 @@
<script lang="ts">
import { onMount, onDestroy, tick } from "svelte";
import type { EditorView } from "prosemirror-view";
import { EditorState, TextSelection } from "prosemirror-state";
import type { Node as ProsemirrorNode } from "prosemirror-model";
import { toggleMark } from "prosemirror-commands";
import type { Mark } from "prosemirror-model";
import BoldIcon from "bootstrap-icons/icons/type-bold.svg";
import ItalicIcon from "bootstrap-icons/icons/type-italic.svg";
import UnderlineIcon from "bootstrap-icons/icons/type-underline.svg";
import StrikethroughIcon from "bootstrap-icons/icons/type-strikethrough.svg";
import LinkIcon from "bootstrap-icons/icons/box-arrow-up-right.svg";
import CloseIcon from "bootstrap-icons/icons/x.svg";
import type { Command } from "./ps-utils";
import {
updateMark,
removeMark,
markIsActive,
commandListener,
getFirstMarkInSelection,
} from "./ps-utils";
import { refreshCoords as _refreshCoords } from "./bubblemenu/coords";
import SimpleMarkItem from "./bubblemenu/SimpleMarkItem.svelte";
export let view: EditorView;
export let state: EditorState;
// == Posicionamiento ==
let bubbleEl: HTMLElement = null;
let left: number = 0;
let bottom: number = 0;
function refreshCoords() {
const coords = _refreshCoords(view, bubbleEl);
left = coords.left;
bottom = coords.bottom;
}
$: {
// refrescar cuando cambia state
view;
state;
if (bubbleEl) refreshCoords();
}
let resizeObserver = new ResizeObserver(() => {
if (bubbleEl) refreshCoords();
});
$: {
view;
resizeObserver.disconnect();
if (bubbleEl) {
resizeObserver.observe(bubbleEl.parentElement);
}
}
onDestroy(() => resizeObserver.disconnect());
// == /Posicionamiento ==
let changingProp:
| false
| { type: "link"; url: string }
| { type: "mark" }
| { type: "mark-custom"; color: string } = false;
let linkInputEl: HTMLElement;
$: {
if (state.selection.empty) {
changingProp = false;
}
if (changingProp && changingProp.type === "link") {
tick().then(() => {
linkInputEl.focus();
});
} else {
view.focus();
}
}
function runCommand(command: Command) {
command(state, view.dispatch);
}
function startEditingLink(event: Event) {
const { to, from } = state.selection;
const match = getFirstMarkInSelection(state, view.state.schema.marks.link);
// si no hay un link en la selección, empezar a editar uno sin ningún enlace
// TODO: quizás queremos poner algo tipo https://sutty.nl por defecto?
if (!match) {
changingProp = { type: "link", url: "" };
return;
}
view.dispatch(
state.tr.setSelection(
TextSelection.create(
state.doc,
match.position,
match.position + match.node.nodeSize
)
)
);
changingProp = { type: "link", url: match.mark.attrs.href };
}
function removeLink() {
changingProp = false;
runCommand(removeMark(view.state.schema.marks.link));
}
function onChangeLink(event: Event) {
changingProp = false;
const url = (event.target as HTMLInputElement).value;
runCommand(updateMark(view.state.schema.marks.link, { href: url }));
}
const svgStyle = "width: 100%; height: 100%";
</script>
<div
bind:this={bubbleEl}
class="bubble"
hidden={state.selection.empty}
style="left: {left}px; bottom: {bottom}px"
>
{#if changingProp === false}
<SimpleMarkItem {view} {state} type={view.state.schema.marks.strong}
><BoldIcon style={svgStyle} /></SimpleMarkItem
>
<SimpleMarkItem {view} {state} type={view.state.schema.marks.em}
><ItalicIcon style={svgStyle} /></SimpleMarkItem
>
<SimpleMarkItem {view} {state} type={view.state.schema.marks.underline}
><UnderlineIcon style={svgStyle} /></SimpleMarkItem
>
<SimpleMarkItem {view} {state} type={view.state.schema.marks.strikethrough}
><StrikethroughIcon style={svgStyle} /></SimpleMarkItem
>
<button
type="button"
class:active={markIsActive(state, view.state.schema.marks.link)}
on:mousedown|preventDefault={startEditingLink}
><LinkIcon style={svgStyle} /></button
>
{:else if changingProp.type === "link"}
<input
bind:this={linkInputEl}
type="text"
placeholder="https://"
on:change|preventDefault={onChangeLink}
value={changingProp.url}
/>
<button
type="button"
title="Borrar enlace"
on:mousedown|preventDefault={removeLink}
><CloseIcon style={svgStyle} /></button
>
{/if}
</div>

81
src/editor/Editor.svelte Normal file
View File

@ -0,0 +1,81 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { keymap } from "prosemirror-keymap";
import { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import { DOMParser, DOMSerializer } from "prosemirror-model";
import type { XmlFragment } from "yjs";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
import "./editor.css";
import { schema } from "./schema";
import BubbleMenu from "./BubbleMenu.svelte";
import MenuBar from "./MenuBar.svelte";
import { placeholderPlugin } from "./upload";
import { baseKeymap } from "./keymap";
export let doc: XmlFragment;
let wrapperEl: HTMLElement;
function createState(doc: XmlFragment): EditorState {
return EditorState.create({
schema,
plugins: [
new Plugin({
view: (editorView) => {
// editorView.dom.parentElement?.replaceWith(editorView.dom);
return {
update(view, lastState) {
if (
lastState &&
lastState.doc.eq(view.state.doc) &&
lastState.selection.eq(view.state.selection)
) {
return;
}
updatedState = view.state;
},
};
},
}),
dropCursor(),
gapCursor(),
//menubar(schema),
// history(),
ySyncPlugin(doc),
// yCursorPlugin(doc.webrtcProvider.awareness),
yUndoPlugin(),
keymap(baseKeymap),
placeholderPlugin,
],
});
}
let state = createState(doc);
$: state = createState(doc);
let updatedState: EditorState = state;
let view: EditorView;
$: {
if (view) view.destroy();
view = new EditorView(wrapperEl, { state });
}
</script>
<div class="editor">
{#if view}
<BubbleMenu {view} state={updatedState} />
<MenuBar {view} state={updatedState} />
{/if}
<!-- this element gets replaced with the editor itself when mounted -->
<div bind:this={wrapperEl} />
</div>

23
src/editor/MenuBar.svelte Normal file
View File

@ -0,0 +1,23 @@
<script lang="ts">
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import BlockSelect from "./menubar/BlockSelect.svelte";
import AlignSelect from "./menubar/AlignSelect.svelte";
// import UploadItem from "./menubar/UploadItem.svelte";
import ListItem from "./menubar/ListItem.svelte";
import BlockQuoteItem from "./menubar/BlockQuoteItem.svelte";
import { ListKind } from "./utils";
export let view: EditorView;
export let state: EditorState;
</script>
<div class="menubar">
<BlockSelect {view} {state} />
<AlignSelect {view} {state} />
<!-- <UploadItem {view} {state} /> -->
<ListItem {view} {state} kind={ListKind.Unordered} />
<ListItem {view} {state} kind={ListKind.Ordered} />
<BlockQuoteItem {view} {state} />
</div>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import type { MarkType } from "prosemirror-model";
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { toggleMark } from "prosemirror-commands";
import { commandListener, markIsActive } from "../ps-utils";
export let view: EditorView<any>;
export let state: EditorState<any>;
export let type: MarkType;
export let small: boolean = false;
$: isActive = markIsActive(state, type);
$: listener = commandListener(view, toggleMark(type));
</script>
<button type="button" class:active={isActive} on:mousedown={listener}>
{#if small}
<small><slot /></small>
{:else}
<slot />
{/if}
</button>

View File

@ -0,0 +1,104 @@
import type { EditorView } from "prosemirror-view";
function textRange(
node: Node,
from: number = 0,
to: number | null = null
): Range {
const range = document.createRange();
range.setEnd(node, to == null ? node.nodeValue.length : to);
range.setStart(node, Math.max(from, 0));
return range;
}
function singleRect(object: Element | Range, bias: number) {
const rects = object.getClientRects();
return !rects.length
? object.getBoundingClientRect()
: rects[bias < 0 ? 0 : rects.length - 1];
}
interface Coords {
top: number;
bottom: number;
left: number;
right: number;
}
function coordsAtPos(
view: EditorView<any>,
pos: number,
end: boolean = false
): Coords {
const { node, offset } = (view as any).docView.domFromPos(pos);
let side: "left" | "right";
let rect: DOMRect;
if (node.nodeType === 3) {
if (end && offset < node.nodeValue.length) {
rect = singleRect(textRange(node, offset - 1, offset), -1);
side = "right";
} else if (offset < node.nodeValue.length) {
rect = singleRect(textRange(node, offset, offset + 1), -1);
side = "left";
}
} else if (node.firstChild) {
if (offset < node.childNodes.length) {
const child = node.childNodes[offset];
rect = singleRect(child.nodeType === 3 ? textRange(child) : child, -1);
side = "left";
}
if ((!rect || rect.top === rect.bottom) && offset) {
const child = node.childNodes[offset - 1];
rect = singleRect(child.nodeType === 3 ? textRange(child) : child, 1);
side = "right";
}
} else {
rect = node.getBoundingClientRect();
side = "left";
}
const x = rect[side];
return {
top: rect.top,
bottom: rect.bottom,
left: x,
right: x,
};
}
export function refreshCoords(view: EditorView<any>, bubbleEl: HTMLElement) {
// Brutally stolen from https://github.com/ueberdosis/tiptap/blob/d2cf88fd166092d6df079cb47fe2a55520fadf80/packages/tiptap/src/Plugins/MenuBubble.js
const { from, to } = view.state.selection;
// These are in screen coordinates
// We can't use EditorView.coordsAtPos here because it can't handle linebreaks correctly
// See: https://github.com/ProseMirror/prosemirror-view/pull/47
const start = coordsAtPos(view, from);
const end = coordsAtPos(view, to, true);
// The box in which the tooltip is positioned, to use as base
const parent = bubbleEl.offsetParent;
if (!parent) {
console.error(
"Me parece que te falto importar el CSS. `import '@suttyweb/editor/dist/style.css';`"
);
// TODO: i18n
throw new Error(
"¡El editor tuvo un error! Contactar a lxs desarrolladorxs."
);
}
const box = parent.getBoundingClientRect();
const el = bubbleEl.getBoundingClientRect();
const _left = Math.max((start.left + end.left) / 2 - box.left, el.width / 2);
return {
left: Math.round(
_left + el.width / 2 > box.width ? box.width - el.width / 2 : _left
),
bottom: Math.round(box.bottom - start.top),
top: Math.round(end.bottom - box.top),
};
}

22
src/editor/demo.css Normal file
View File

@ -0,0 +1,22 @@
@import "fork-awesome/css/fork-awesome.min.css";
* {
box-sizing: border-box;
text-rendering: optimizeLegibility;
}
*::before,
*::after {
box-sizing: border-box;
}
html {
margin: 1rem;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
.editor {
max-width: 60rem;
margin: 2rem auto;
}

194
src/editor/editor.css Normal file
View File

@ -0,0 +1,194 @@
@import "prosemirror-view/style/prosemirror.css";
.editor {
position: relative;
}
.editor *::before,
.editor *::after {
box-sizing: border-box;
}
/* para ver los cambios con el sobrerayado */
::selection,
::-moz-selection {
background: rgba(0, 0, 0, 0.3);
}
.editor .menubar {
position: sticky;
top: 0px;
z-index: 69;
background: white;
border-bottom: 1px solid #bbb;
}
.editor .menubar .separator {
border-right: 2px solid #bbb;
margin: 0 0.5rem;
}
.editor .menubar button {
appearance: none;
background: none;
border: none;
border-radius: 2px;
line-height: 1;
padding: 0.4em 0.6em;
margin: 0.2em;
transition: all 0.2s;
}
.editor .menubar button.active {
background: #ddd;
}
.editor .menubar button:hover {
background: #eee;
}
.editor .menubar button svg {
width: 1.5rem;
height: 1.5rem;
}
.editor .bubble {
display: flex !important;
position: absolute;
z-index: 420;
transform: translateX(-50%);
background: black;
color: white;
border-radius: 5px;
padding: 0rem;
margin-bottom: 0.5rem;
visibility: visible;
opacity: 1;
transition: opacity 0.2s, visibility 0.2s;
}
.editor .bubble[hidden] {
visibility: hidden;
opacity: 0;
}
.editor .bubble input {
appearance: none;
background: none;
color: inherit;
border: none;
font-size: 1.25em;
}
.editor .bubble .separator {
border-right: 1px solid #777;
margin: 0 0.5rem;
}
.editor .bubble button {
appearance: none;
background: none;
color: inherit;
border: none;
border-radius: 2px;
padding: 0.3em;
margin: 0.2em;
width: 1.8rem;
height: 1.8rem;
font-size: 1em;
line-height: 1;
transition: background 0.2s;
}
.editor .bubble button:hover {
background: #333;
}
.editor .bubble button.active {
background: #555;
}
.editor .bubble .color-button {
padding: 0.4em;
}
.editor .bubble .color {
display: inline-block;
width: 1em;
height: 1em;
border-radius: 100%;
}
.editor .bubble p {
margin: 0;
}
.ProseMirror {
width: 100%;
padding: 0.5em;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.ProseMirror:focus {
outline: lightskyblue solid;
}
.ProseMirror img {
max-width: 100%;
}
.ProseMirror figure {
margin: 0;
padding: 0.5em 2em;
}
.ProseMirror figure figcaption::before {
content: "Descripción: ";
color: #666;
}
.ProseMirror .ProseMirror-multimedia-placeholder {
margin-bottom: 0.5em;
}
.ProseMirror .ProseMirror-multimedia-placeholder::before {
content: "Clickea aquí para subir una imágen, audio o documento.";
color: #666;
}
.ProseMirror blockquote {
background-color: #f5f5f5;
border-left: 5px solid #dbdbdb;
padding: 1.25em 1.5em;
margin: 0.5em;
}
.ProseMirror-menubar {
position: sticky;
top: 0px;
z-index: 69;
background: white;
border-bottom: 1px solid #bbb;
}
.ProseMirror-menubar-separator {
border-right: 2px solid #bbb;
margin: 0 0.5rem;
}
.ProseMirror-menubar-button {
appearance: none;
background: none;
border: none;
border-radius: 8px;
font-size: 1em;
padding: 0.4em 0.6em;
margin: 0.2em;
transition: all 0.2s;
}
.ProseMirror-menubar-button-active {
background: #ddd;
}
.ProseMirror-menubar-button:hover {
background: #eee;
}

2
src/editor/global.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

69
src/editor/keymap.ts Normal file
View File

@ -0,0 +1,69 @@
// derivado de prosemirror-commands/src/commands.js
import {
chainCommands,
createParagraphNear,
deleteSelection,
exitCode,
joinBackward,
joinForward,
liftEmptyBlock,
newlineInCode,
selectAll,
selectNodeBackward,
selectNodeForward,
splitBlock,
} from "prosemirror-commands";
import { undo, redo } from "y-prosemirror";
import {
splitListItem,
liftListItem,
sinkListItem,
} from "prosemirror-schema-list";
import { schema } from "./schema";
const backspace = chainCommands(
deleteSelection,
joinBackward,
selectNodeBackward
);
const del = chainCommands(deleteSelection, joinForward, selectNodeForward);
const pcBaseKeymap = {
Enter: chainCommands(
newlineInCode,
createParagraphNear,
liftEmptyBlock,
// XXX: hack
splitListItem(schema.nodes.list_item as any),
splitBlock
),
"Mod-Enter": chainCommands(exitCode, splitBlock),
Backspace: backspace,
"Mod-Backspace": backspace,
Delete: del,
"Mod-Delete": del,
"Mod-a": selectAll,
"Shift-Tab": liftListItem(schema.nodes.list_item),
Tab: sinkListItem(schema.nodes.list_item),
};
const macBaseKeymap = {
...pcBaseKeymap,
"Ctrl-h": pcBaseKeymap["Backspace"],
"Alt-Backspace": pcBaseKeymap["Mod-Backspace"],
"Ctrl-d": pcBaseKeymap["Delete"],
"Ctrl-Alt-Backspace": pcBaseKeymap["Mod-Delete"],
"Alt-Delete": pcBaseKeymap["Mod-Delete"],
"Alt-d": pcBaseKeymap["Mod-Delete"],
};
const mac =
typeof navigator != "undefined" ? /Mac/.test(navigator.platform) : false;
export const baseKeymap = {
...(mac ? macBaseKeymap : pcBaseKeymap),
"Mod-z": undo,
"Mod-y": redo,
};

11
src/editor/main.ts Normal file
View File

@ -0,0 +1,11 @@
import "./demo.css";
import Editor from "./Editor.svelte";
const editor = new Editor({
target: document.body,
props: {
textareaEl: document.body.querySelector("textarea"),
},
});
export default editor;

View File

@ -0,0 +1,30 @@
<script lang="ts">
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { getAttrFn, setAlign } from "../ps-utils";
import type { Align } from "../schema";
export let view: EditorView;
export let state: EditorState;
$: isPossible = setAlign(null)(state, null);
$: currentValue = getAttrFn("align")(state);
const onChange = (event) => {
event.preventDefault();
const { value } = event.target;
const align: Align = value === "normal" ? null : value;
setAlign(align)(state, view.dispatch);
};
</script>
<select
value={currentValue === null ? "normal" : currentValue}
disabled={!isPossible}
on:change={onChange}
>
<option value="normal">Normal</option>
<option value="center">Centro</option>
<option value="right">Derecha</option>
</select>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { wrapIn } from "prosemirror-commands";
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import BlockquoteIcon from "bootstrap-icons/icons/blockquote-left.svg";
import { commandListener, nodeIsActiveFn } from "../ps-utils";
export let view: EditorView;
export let state: EditorState;
const type = state.schema.nodes.blockquote;
$: isActive = nodeIsActiveFn(type, null, true);
$: command = wrapIn(type);
$: isPossible = command(state, null);
$: actionListener = commandListener(view, command);
</script>
<button
type="button"
class:active={isActive(state)}
on:mousedown={actionListener}
disabled={!isPossible}><BlockquoteIcon /></button
>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import type { NodeType } from "prosemirror-model";
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { setBlockType } from "prosemirror-commands";
export let view: EditorView;
export let state: EditorState;
const paragraphType = state.schema.nodes.paragraph;
const headingType = state.schema.nodes.heading;
$: isPossible = setBlockType(headingType, { level: 1 })(state, null);
$: currentValue =
state.selection.to <= state.selection.$from.end() &&
(state.selection.$from.parent.type == headingType
? `heading:${state.selection.$from.parent.attrs.level}`
: state.selection.$from.parent.type == paragraphType
? "paragraph"
: null);
const onChange = (event) => {
event.preventDefault();
const [type, param] = event.target.value.split(":");
if (type === "paragraph") {
setBlockType(paragraphType, {
align: state.selection.$from.parent.attrs.align,
})(state, view.dispatch);
} else if (type === "heading") {
setBlockType(headingType, {
level: parseInt(param),
align: state.selection.$from.parent.attrs.align,
})(state, view.dispatch);
} else {
console.error(`¡type no es heading ni paragraph! Es`, type);
}
};
</script>
<select value={currentValue} on:change={onChange}>
<option value="paragraph">Párrafo</option>
<option value="heading:1">Titulo grande</option>
<option value="heading:2">Titulo mediano</option>
<option value="heading:3">Subtitulo</option>
<option value="heading:4">Subsubtitulo</option>
<option value="heading:5">Subsubsubtitulo</option>
<option value="heading:6">Subsubsubsubtitulo</option>
</select>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import { chainCommands } from "prosemirror-commands";
import { wrapInList } from "prosemirror-schema-list";
import { liftListItem } from "prosemirror-schema-list";
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import UlIcon from "bootstrap-icons/icons/list-ul.svg";
import OlIcon from "bootstrap-icons/icons/list-ol.svg";
import { ListKind } from "../utils";
import { commandListener, nodeIsActiveFn } from "../ps-utils";
export let view: EditorView;
export let state: EditorState;
export let kind: ListKind;
$: type =
kind === ListKind.Unordered
? state.schema.nodes.bullet_list
: kind === ListKind.Ordered
? state.schema.nodes.ordered_list
: null;
const listItemType = state.schema.nodes.list_item;
$: iconComponent =
kind === ListKind.Unordered
? UlIcon
: kind === ListKind.Ordered
? OlIcon
: (console.error("adsfadsf"), UlIcon);
$: isActive = nodeIsActiveFn(type, null, true);
$: command = chainCommands(liftListItem(listItemType), wrapInList(type));
$: isPossible = command(state, null);
$: actionListener = commandListener(view, command);
</script>
<button
type="button"
class:active={isActive(state)}
on:mousedown={actionListener}
disabled={!isPossible}><svelte:component this={iconComponent} /></button
>

View File

@ -0,0 +1,86 @@
<script lang="ts">
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { insertPoint } from "prosemirror-transform";
import { uploadFile, placeholderPlugin, findPlaceholder } from "../upload";
import type { NodeType } from "prosemirror-model";
import type { MultimediaKind } from "../schema";
export let view: EditorView;
export let state: EditorState;
const type = state.schema.nodes.multimedia;
let inputEl: HTMLInputElement;
function mimeToKind(mime: string): MultimediaKind {
if (mime.match(/^image\/.+$/)) {
return "img";
} else if (mime.match(/^video\/.+$/)) {
return "video";
} else if (mime.match(/^audio\/.+$/)) {
return "audio";
} else if (mime.match(/^application\/pdf$/)) {
return "iframe";
} else {
// TODO: chequear si el archivo es válido antes de subir
throw new Error("Tipo de archivo no reconocido");
}
}
function startUploading() {
inputEl.click();
}
function upload() {
// TODO: permitir reemplazar el contenido de multimedia de un multimedia
// ya existente
if (
inputEl.files.length > 0 &&
state.selection.$from.parent.inlineContent
) {
let id = {};
let tr = view.state.tr;
const point = insertPoint(state.doc, state.selection.to, type);
tr.setMeta(placeholderPlugin, { add: { id, pos: point } });
view.dispatch(tr);
const file = inputEl.files[0];
uploadFile(file)
.then((url) => {
const placeholderPos = findPlaceholder(view.state, id);
// si se borró el placeholder, no subir imágen
if (placeholderPos == null) return;
const node = state.schema.nodes.multimedia.createChecked(
{
src: url,
kind: mimeToKind(file.type),
},
[state.schema.nodes.multimedia_caption.createChecked({})]
);
view.dispatch(
view.state.tr
.replaceWith(placeholderPos, placeholderPos, node)
.setMeta(placeholderPlugin, { remove: { id } })
);
})
.catch((err) => {
console.error(err);
// TODO: mostrar error
alert(err);
view.dispatch(tr.setMeta(placeholderPlugin, { remove: { id } }));
});
}
view.focus();
}
</script>
<button type="button" on:mousedown|preventDefault={startUploading}
><i class={`fa fa-upload`} /></button
>
<input
type="file"
bind:this={inputEl}
on:change|preventDefault={upload}
hidden
/>

237
src/editor/ps-utils.ts Normal file
View File

@ -0,0 +1,237 @@
import { chainCommands, setBlockType } from "prosemirror-commands";
import type {
Mark,
MarkType,
NodeType,
ResolvedPos,
Node as ProsemirrorNode,
} from "prosemirror-model";
import type { EditorState, Selection } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import type { Align } from "./schema";
export type Command = (
state: EditorState<any>,
dispatch?: EditorView<any>["dispatch"]
) => boolean;
// A lot of this is from https://github.com/ueberdosis/tiptap/blob/main/packages/tiptap-commands
export function getMarkRange(
$pos: ResolvedPos | null = null,
type: MarkType | null = null
) {
if (!$pos || !type) {
return false;
}
const start = $pos.parent.childAfter($pos.parentOffset);
if (!start.node) {
return false;
}
const link = start.node.marks.find((mark) => mark.type === type);
if (!link) {
return false;
}
let startIndex = $pos.index();
let startPos = $pos.start() + start.offset;
let endIndex = startIndex + 1;
let endPos = startPos + start.node.nodeSize;
while (
startIndex > 0 &&
link.isInSet($pos.parent.child(startIndex - 1).marks)
) {
startIndex -= 1;
startPos -= $pos.parent.child(startIndex).nodeSize;
}
while (
endIndex < $pos.parent.childCount &&
link.isInSet($pos.parent.child(endIndex).marks)
) {
endPos += $pos.parent.child(endIndex).nodeSize;
endIndex += 1;
}
return { from: startPos, to: endPos };
}
export function updateMark(type: MarkType, attrs: any): Command {
return (state, dispatch) => {
const { tr, selection, doc } = state;
const { ranges, empty } = selection;
if (empty) {
const range = getMarkRange(selection.$from, type);
if (!range) throw new Error("What the fuck");
const { from, to } = range;
if (doc.rangeHasMark(from, to, type)) {
tr.removeMark(from, to, type);
}
tr.addMark(from, to, type.create(attrs));
} else {
ranges.forEach((ref$1) => {
const { $to, $from } = ref$1;
if (doc.rangeHasMark($from.pos, $to.pos, type)) {
tr.removeMark($from.pos, $to.pos, type);
}
tr.addMark($from.pos, $to.pos, type.create(attrs));
});
}
if (dispatch) {
dispatch(tr);
}
return true;
};
}
export function removeMark(type: MarkType): Command {
return (state, dispatch) => {
const { tr, selection } = state;
let { from, to } = selection;
const { $from, empty } = selection;
if (empty) {
const range = getMarkRange($from, type);
if (!range) throw new Error("No");
from = range.from;
to = range.to;
}
tr.removeMark(from, to, type);
if (dispatch) {
dispatch(tr);
}
return true;
};
}
export function toggleNode(
type: NodeType<any>,
attrs: any,
/// es el tipo que se setea si ya es el type querido; probablemente querés
/// que sea el type de párrafo
alternateType: NodeType<any>
): Command {
return chainCommands(setBlockType(type, attrs), setBlockType(alternateType));
}
export function commandListener(
view: EditorView<any>,
command: Command
): (event: Event) => void {
return (event) => {
event.preventDefault();
command(view.state, view.dispatch);
};
}
export default function findParentNodeClosestToPos(
$pos: ResolvedPos,
predicate: (node: ProsemirrorNode<any>) => boolean
) {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i);
if (predicate(node)) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node,
};
}
}
}
export function nodeIsActiveFn(
type: NodeType<any>,
attrs?: any,
checkParents: boolean = false
): (state: EditorState) => boolean {
return (state) => {
let { $from, to } = state.selection;
return (
to <= $from.end() &&
(checkParents
? !!findParentNodeClosestToPos($from, (n) => n.type == type)
: $from.parent.hasMarkup(type, attrs))
);
};
}
export function getAttrFn(attrKey: string): (state: EditorState) => any {
return (state) => {
let { from, to } = state.selection;
let value: any = undefined;
state.doc.nodesBetween(from, to, (node, pos) => {
if (value !== undefined) return false;
if (!node.isTextblock) return;
if (attrKey in node.attrs) value = node.attrs[attrKey];
});
return value;
};
}
export function markIsActive(state: EditorState, type: MarkType<any>): boolean {
let { from, to } = state.selection;
return state.doc.rangeHasMark(from, to, type);
}
export interface MarkMatch {
node: ProsemirrorNode<any>;
position: number;
mark: Mark;
}
export function getFirstMarkInSelection(
state: EditorState,
type: MarkType<any>
): MarkMatch {
const { to, from } = state.selection;
let match: MarkMatch;
state.selection.$from.doc.nodesBetween(from, to, (node, position) => {
if (!match) {
const mark = type.isInSet(node.marks);
if (!mark) return;
match = { node, position, mark };
}
});
return match;
}
export function setAlign(align: Align): Command {
return (state, dispatch) => {
let { from, to } = state.selection;
let node: ProsemirrorNode<any> | null = null;
state.doc.nodesBetween(from, to, (_node, pos) => {
if (node) return false;
if (!_node.isTextblock) return;
if (
_node.type == state.schema.nodes.paragraph ||
_node.type == state.schema.nodes.heading
) {
node = _node;
}
});
if (!node) return false;
if (dispatch)
return setBlockType(node.type, { ...node.attrs, align })(state, dispatch);
return true;
};
}

326
src/editor/schema.ts Normal file
View File

@ -0,0 +1,326 @@
import { Schema } from "prosemirror-model";
const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2);
// https://stackoverflow.com/a/3627747
// TODO: cambiar por una solución más copada
function rgbToHex(rgb: string): string {
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
if (!matches) throw new Error("no pude parsear el rgb()");
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]);
}
export type Align = null | "center" | "right";
export type MultimediaKind = "img" | "video" | "audio" | "iframe";
function getAlign(node: HTMLElement): Align | null {
let align = node.style.textAlign || node.getAttribute("data-align");
if (align !== "center" && align !== "right") return null;
return align;
}
function getHeadingAttrs(
level: number
): (n: Node) => { [key: string]: string } {
return (n) => ({ level, align: getAlign(n as HTMLElement) });
}
export const schema = new Schema({
nodes: {
doc: {
content: "block+",
},
paragraph: {
content: "inline*",
group: "block",
attrs: { align: { default: null } },
parseDOM: [
{
tag: "p",
getAttrs(n) {
return { align: getAlign(n as HTMLElement) };
},
},
],
toDOM(node) {
return ["p", { style: `text-align: ${node.attrs.align}` }, 0];
},
},
blockquote: {
content: "block+",
group: "block",
parseDOM: [{ tag: "blockquote" }],
toDOM() {
return ["blockquote", 0];
},
},
horizontal_rule: {
group: "block",
parseDOM: [{ tag: "hr" }],
toDOM() {
return ["div", ["hr"]];
},
},
heading: {
attrs: { level: { default: 1 }, align: { default: null } },
content: "text*",
group: "block",
defining: true,
parseDOM: [
{ tag: "h1", getAttrs: getHeadingAttrs(1) },
{ tag: "h2", getAttrs: getHeadingAttrs(2) },
{ tag: "h3", getAttrs: getHeadingAttrs(3) },
{ tag: "h4", getAttrs: getHeadingAttrs(4) },
{ tag: "h5", getAttrs: getHeadingAttrs(5) },
{ tag: "h6", getAttrs: getHeadingAttrs(6) },
],
toDOM(node) {
return [
"h" + node.attrs.level,
{ style: `text-align: ${node.attrs.align}` },
0,
];
},
},
code_block: {
content: "text*",
group: "block",
code: true,
defining: true,
marks: "",
attrs: { params: { default: "" } },
parseDOM: [
{
tag: "pre",
preserveWhitespace: "full",
getAttrs: (node) => ({
params: (node as Element).getAttribute("data-params") || "",
}),
},
],
toDOM(node) {
return [
"pre",
node.attrs.params ? { "data-params": node.attrs.params } : {},
["code", 0],
];
},
},
ordered_list: {
content: "list_item+",
group: "block",
attrs: { order: { default: 1 } },
parseDOM: [
{
tag: "ol",
getAttrs(dom: Element) {
return {
order: dom.hasAttribute("start") ? +dom.getAttribute("start") : 1,
};
},
},
],
toDOM(node) {
return node.attrs.order == 1
? ["ol", 0]
: ["ol", { start: node.attrs.order }, 0];
},
},
bullet_list: {
content: "list_item+",
group: "block",
parseDOM: [{ tag: "ul" }],
toDOM: () => ["ul", 0],
},
list_item: {
content: "paragraph block*",
defining: true,
parseDOM: [{ tag: "li" }],
toDOM() {
return ["li", 0];
},
},
text: {
group: "inline",
},
multimedia: {
group: "block",
attrs: { src: {}, kind: {} },
content: "text*",
parseDOM: [
{
tag: "figure",
getAttrs(dom) {
const child: HTMLElement =
(dom as Element).querySelector("img") ||
(dom as Element).querySelector("video") ||
(dom as Element).querySelector("audio") ||
(dom as Element).querySelector("iframe");
if (child instanceof HTMLImageElement) {
return { src: child.src, kind: "img" };
} else if (child instanceof HTMLVideoElement) {
return { src: child.src, kind: "video" };
} else if (child instanceof HTMLAudioElement) {
return { src: child.src, kind: "audio" };
} else if (child instanceof HTMLIFrameElement) {
return { src: child.src, kind: "iframe" };
}
},
},
{
tag: "img",
getAttrs(dom) {
return { src: (dom as HTMLImageElement).src, kind: "img" };
},
},
{
tag: "video",
getAttrs(dom) {
return { src: (dom as HTMLVideoElement).src, kind: "video" };
},
},
{
tag: "audio",
getAttrs(dom) {
return { src: (dom as HTMLAudioElement).src, kind: "audio" };
},
},
{
tag: "iframe",
getAttrs(dom) {
return { src: (dom as HTMLIFrameElement).src, kind: "iframe" };
},
},
],
toDOM(node) {
return [
"figure",
[node.attrs.kind, { src: node.attrs.src }],
["figcaption", 0],
];
},
draggable: true,
},
hard_break: {
inline: true,
group: "inline",
selectable: false,
parseDOM: [{ tag: "br" }],
toDOM() {
return ["br"];
},
},
},
marks: {
em: {
parseDOM: [
{ tag: "i" },
{ tag: "em" },
{ style: "font-style", getAttrs: (value) => value == "italic" && null },
],
toDOM() {
return ["em"];
},
},
strong: {
parseDOM: [
{ tag: "b" },
{ tag: "strong" },
{
style: "font-weight",
getAttrs: (value) =>
/^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null,
},
],
toDOM() {
return ["strong"];
},
},
underline: {
parseDOM: [{ tag: "u" }],
toDOM() {
return ["u"];
},
},
strikethrough: {
parseDOM: [{ tag: "del" }],
toDOM() {
return ["del"];
},
},
small: {
parseDOM: [{ tag: "small" }],
toDOM() {
return ["small"];
},
},
mark: {
attrs: {
color: { default: "#f206f9" },
},
parseDOM: [
{
tag: "mark",
getAttrs(dom) {
const prop = (dom as HTMLElement).style.backgroundColor;
const hex = rgbToHex(prop);
return {
color: hex,
};
},
},
],
toDOM(node) {
return ["mark", { style: `background-color:${node.attrs.color}` }];
},
},
link: {
attrs: {
href: {},
},
inclusive: false,
parseDOM: [
{
tag: "a[href]",
getAttrs(dom) {
return {
href: (dom as Element).getAttribute("href"),
};
},
},
],
toDOM(node) {
const attrs = {
...node.attrs,
rel: "noopener",
referrerpolicy: "strict-origin-when-cross-origin",
};
return ["a", attrs];
},
},
code: {
parseDOM: [{ tag: "code" }],
toDOM() {
return ["code"];
},
},
},
});

48
src/editor/upload.ts Normal file
View File

@ -0,0 +1,48 @@
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { h } from "./utils";
export function uploadFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
reject("TODO: implementar subidas");
});
}
export const placeholderPlugin = new Plugin({
state: {
init(): DecorationSet {
return DecorationSet.empty;
},
apply(tr, set: DecorationSet) {
// Adjust decoration positions to changes made by the transaction
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
let action = tr.getMeta(this);
if (action && action.add) {
let widgetEl = h("div", { class: "ProseMirror-placeholder" }, [
"Subiendo archivo...",
]);
let deco = Decoration.widget(action.add.pos, widgetEl, {
id: action.add.id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(
set.find(undefined, undefined, (spec) => spec.id == action.remove.id)
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
export function findPlaceholder(state: EditorState, id: any): number | null {
const decos: DecorationSet = placeholderPlugin.getState(state);
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
return found.length ? found[0].from : null;
}

49
src/editor/utils.ts Normal file
View File

@ -0,0 +1,49 @@
interface Props {
dataset?: { [key: string]: string };
attributes?: { [key: string]: string };
contenteditable?: "true" | "false";
class?: string;
on?: { [key: string]: EventListener };
}
export function h(
tagName: string,
props: Props,
children: (Node | string | undefined)[]
): HTMLElement {
const el = document.createElement(tagName);
if (props.class) {
el.setAttribute("class", props.class);
}
if (props.dataset) {
for (const [key, value] of Object.entries(props.dataset)) {
el.dataset[key] = value;
}
}
if (props.contenteditable) {
el.contentEditable = props.contenteditable;
}
if (props.attributes) {
for (const [key, value] of Object.entries(props.attributes)) {
el.setAttribute(key, value);
}
}
if (props.on) {
for (const [key, value] of Object.entries(props.on)) {
el.addEventListener(key, value);
}
}
for (const node of children) {
if (typeof node === "string") {
el.appendChild(document.createTextNode(node));
} else if (node) {
el.appendChild(node);
}
}
return el;
}
export enum ListKind {
Unordered,
Ordered,
}

37
src/lib/doc.ts Normal file
View File

@ -0,0 +1,37 @@
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
import { nanoid } from "nanoid";
export type WorldIdentifier = {
room: string;
password: string;
};
export type WorldY = {
ydoc: Y.Doc;
webrtcProvider: WebrtcProvider;
};
export function generateNewWorld(): WorldIdentifier {
return {
room: nanoid(),
password: nanoid(),
};
}
export function getWorldY(world: WorldIdentifier): WorldY {
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(world.room, ydoc, {
password: world.password,
signaling: [
"wss://signaling.yjs.dev",
"wss://y-webrtc-signaling-eu.herokuapp.com",
"wss://y-webrtc-signaling-us.herokuapp.com",
],
});
return { ydoc, webrtcProvider: provider };
}
export function getWorldPage(ydoc: Y.Doc, pageId: string): Y.XmlFragment {
return ydoc.getXmlFragment(`doc/${pageId}`);
}

34
src/lib/routes.ts Normal file
View File

@ -0,0 +1,34 @@
import navaid from "navaid";
import { writable } from "svelte/store";
import ChooseWorld from "../views/ChooseWorld.svelte";
import CreateWorld from "../views/CreateWorld.svelte";
import NotFound from "../views/NotFound.svelte";
import Page from "../views/Page.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",
Page: "/w/:worldId/:pageId",
};
router.on(routes.ChooseWorld, () =>
currentRoute.set({ component: ChooseWorld })
);
router.on(routes.CreateWorld, () =>
currentRoute.set({ component: CreateWorld })
);
router.on(routes.Page, (params) =>
currentRoute.set({ component: Page, params })
);
router.listen();

18
src/lib/worldStorage.ts Normal file
View File

@ -0,0 +1,18 @@
import type { WorldIdentifier } from "./doc";
const localStorageKey = "schreiben-worlds";
export function loadWorlds(): Promise<WorldIdentifier[]> {
let json = localStorage.getItem(localStorageKey);
if (!json) json = "[]";
return Promise.resolve(JSON.parse(json));
}
export async function writeWorlds(
callback: (worlds: WorldIdentifier[]) => WorldIdentifier[]
): Promise<WorldIdentifier[]> {
const oldWorlds = await loadWorlds();
const newWorlds = callback(oldWorlds);
localStorage.setItem(localStorageKey, JSON.stringify(newWorlds));
return newWorlds;
}

8
src/main.ts Normal file
View File

@ -0,0 +1,8 @@
import './app.css'
import App from './App.svelte'
const app = new App({
target: document.getElementById('app'),
})
export default app

View File

@ -0,0 +1,25 @@
<script lang="ts">
import { loadWorlds } from "../lib/worldStorage";
import { routes } from "../lib/routes";
const worldsPromise = loadWorlds();
</script>
<h1>Buen día.</h1>
<h3>Elegí un mundo.</h3>
{#await worldsPromise then worlds}
<ul>
{#each worlds as world}
<li>
<a
href={routes.Page.replace(":worldId", world.room).replace(
":pageId",
"index"
)}>{world.room}</a
>
</li>
{/each}
<li><a href={routes.CreateWorld}>Crear mundo</a></li>
</ul>
{/await}

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { generateNewWorld } from "../lib/doc";
import { router, routes } from "../lib/routes";
import { writeWorlds } from "../lib/worldStorage";
function crear(
event: Event & { readonly submitter: HTMLElement } & {
currentTarget: EventTarget & HTMLFormElement;
}
) {
event.preventDefault();
writeWorlds((worlds) => [...worlds, generateNewWorld()]);
router.run(routes.ChooseWorld);
}
</script>
<form on:submit={crear}>
<button>Crear mundo</button>
</form>

View File

@ -0,0 +1,3 @@
<h1>No encontré esa página</h1>
<!-- TODO: llevar a index -->

45
src/views/Page.svelte Normal file
View File

@ -0,0 +1,45 @@
<script lang="ts">
import { onDestroy } from "svelte";
import type { XmlFragment } from "yjs";
import Editor from "../editor/Editor.svelte";
import { getWorldPage, getWorldY, type WorldY } from "../lib/doc";
import { routes } from "../lib/routes";
import { loadWorlds } from "../lib/worldStorage";
export let worldId: string;
export let pageId: string;
async function loadDoc(
worldId: string,
pageId: string
): Promise<{ worldY: WorldY; doc: XmlFragment }> {
const worlds = await loadWorlds();
const worldIdentifier = worlds.find((w) => w.room === worldId);
if (!worldIdentifier) {
throw new Error("No conozco ese mundo.");
}
const worldY = getWorldY(worldIdentifier);
return { worldY, doc: getWorldPage(worldY.ydoc, pageId) };
}
$: docPromise = loadDoc(worldId, pageId);
onDestroy(async () => {
const doc = await docPromise;
doc.worldY.webrtcProvider.destroy();
});
</script>
<a class="no-color" href={routes.ChooseWorld}>🠔 Elegir otro mundo</a>
{#await docPromise then doc}
<Editor doc={doc.doc} />
{:catch error}
{error}
<a href={routes.ChooseWorld}>Volver al inicio</a>
{/await}
<style>
.no-color {
color: inherit;
}
</style>

2
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

7
svelte.config.js Normal file
View File

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

8
tsconfig.node.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node"
},
"include": ["vite.config.ts"]
}

26
vite.config.ts Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import svg from "@poppanator/sveltekit-svg";
import basicSsl from "@vitejs/plugin-basic-ssl";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
svelte(),
svg({
svgoOptions: {
multipass: true,
plugins: [
{
name: "preset-default",
// by default svgo removes the viewBox which prevents svg icons from scaling
// not a good idea! https://github.com/svg/svgo/pull/1461
params: { overrides: { removeViewBox: false } },
},
],
},
}),
// lo necesitamos para crypto.subtle para nanoid
basicSsl(),
],
});