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