init
This commit is contained in:
commit
007685f62a
41 changed files with 3247 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
5
README.md
Normal file
5
README.md
Normal 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
13
index.html
Normal 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
42
package.json
Normal 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
1235
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
43
src/App.svelte
Normal file
43
src/App.svelte
Normal 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
15
src/app.css
Normal 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
26
src/app.d.ts
vendored
Normal 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;
|
||||
}
|
163
src/editor/BubbleMenu.svelte
Normal file
163
src/editor/BubbleMenu.svelte
Normal 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
81
src/editor/Editor.svelte
Normal 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
23
src/editor/MenuBar.svelte
Normal 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>
|
24
src/editor/bubblemenu/SimpleMarkItem.svelte
Normal file
24
src/editor/bubblemenu/SimpleMarkItem.svelte
Normal 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>
|
104
src/editor/bubblemenu/coords.ts
Normal file
104
src/editor/bubblemenu/coords.ts
Normal 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
22
src/editor/demo.css
Normal 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
194
src/editor/editor.css
Normal 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
2
src/editor/global.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
69
src/editor/keymap.ts
Normal file
69
src/editor/keymap.ts
Normal 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
11
src/editor/main.ts
Normal 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;
|
30
src/editor/menubar/AlignSelect.svelte
Normal file
30
src/editor/menubar/AlignSelect.svelte
Normal 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>
|
27
src/editor/menubar/BlockQuoteItem.svelte
Normal file
27
src/editor/menubar/BlockQuoteItem.svelte
Normal 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
|
||||
>
|
49
src/editor/menubar/BlockSelect.svelte
Normal file
49
src/editor/menubar/BlockSelect.svelte
Normal 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>
|
44
src/editor/menubar/ListItem.svelte
Normal file
44
src/editor/menubar/ListItem.svelte
Normal 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
|
||||
>
|
86
src/editor/menubar/UploadItem.svelte
Normal file
86
src/editor/menubar/UploadItem.svelte
Normal 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
237
src/editor/ps-utils.ts
Normal 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
326
src/editor/schema.ts
Normal 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
48
src/editor/upload.ts
Normal 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
49
src/editor/utils.ts
Normal 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
37
src/lib/doc.ts
Normal 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
34
src/lib/routes.ts
Normal 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
18
src/lib/worldStorage.ts
Normal 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
8
src/main.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app'),
|
||||
})
|
||||
|
||||
export default app
|
25
src/views/ChooseWorld.svelte
Normal file
25
src/views/ChooseWorld.svelte
Normal 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}
|
19
src/views/CreateWorld.svelte
Normal file
19
src/views/CreateWorld.svelte
Normal 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>
|
3
src/views/NotFound.svelte
Normal file
3
src/views/NotFound.svelte
Normal file
|
@ -0,0 +1,3 @@
|
|||
<h1>No encontré esa página</h1>
|
||||
|
||||
<!-- TODO: llevar a index -->
|
45
src/views/Page.svelte
Normal file
45
src/views/Page.svelte
Normal 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
2
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
7
svelte.config.js
Normal file
7
svelte.config.js
Normal 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
20
tsconfig.json
Normal 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
8
tsconfig.node.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
26
vite.config.ts
Normal file
26
vite.config.ts
Normal 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(),
|
||||
],
|
||||
});
|
Loading…
Reference in a new issue