Compare commits

...

9 commits

Author SHA1 Message Date
42a90f99f0 limpiar bubblemenu
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-08-31 15:05:53 -03:00
2b44ab6102 apropiadamente implementar nuevo linkeo 2023-08-31 15:02:58 -03:00
15ffbae34d mejorar scroll breadcrumbs 2023-08-31 14:28:35 -03:00
8b12af122a nav padding 2023-08-31 14:19:59 -03:00
ce1379ee30 streamline menubar css 2023-08-31 14:18:13 -03:00
15dc942f45 tranquilizar linking dark 2023-08-31 13:48:00 -03:00
b8901b904d theme linktooltip 2023-08-31 13:47:15 -03:00
6f52321632 theme linktooltip y editlinkmenu 2023-08-31 13:30:15 -03:00
91cbf2260b traer cambios de enlaces de sutty/editor 2023-08-31 10:36:21 -03:00
14 changed files with 450 additions and 128 deletions

View file

@ -33,7 +33,7 @@
"prosemirror-schema-list": "~1.2.2", "prosemirror-schema-list": "~1.2.2",
"prosemirror-state": "~1.4.2", "prosemirror-state": "~1.4.2",
"prosemirror-transform": "~1.7.1", "prosemirror-transform": "~1.7.1",
"prosemirror-view": "~1.29.2", "prosemirror-view": "^1.31.7",
"svelte": "^3.58.0", "svelte": "^3.58.0",
"svelte-check": "^2.10.3", "svelte-check": "^2.10.3",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
@ -42,6 +42,7 @@
"vite": "^4.2.2" "vite": "^4.2.2"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.5.1",
"bootstrap-icons": "^1.10.4", "bootstrap-icons": "^1.10.4",
"eva-icons": "^1.1.3", "eva-icons": "^1.1.3",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",

View file

@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
dependencies: dependencies:
'@floating-ui/dom':
specifier: ^1.5.1
version: 1.5.1
bootstrap-icons: bootstrap-icons:
specifier: ^1.10.4 specifier: ^1.10.4
version: 1.10.4 version: 1.10.4
@ -28,7 +31,7 @@ dependencies:
version: 9.0.10(yjs@13.5.52) version: 9.0.10(yjs@13.5.52)
y-prosemirror: y-prosemirror:
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1(prosemirror-model@1.18.3)(prosemirror-state@1.4.2)(prosemirror-view@1.29.2)(y-protocols@1.0.5)(yjs@13.5.52) version: 1.2.1(prosemirror-model@1.18.3)(prosemirror-state@1.4.2)(prosemirror-view@1.31.7)(y-protocols@1.0.5)(yjs@13.5.52)
y-protocols: y-protocols:
specifier: ^1.0.5 specifier: ^1.0.5
version: 1.0.5 version: 1.0.5
@ -104,8 +107,8 @@ devDependencies:
specifier: ~1.7.1 specifier: ~1.7.1
version: 1.7.1 version: 1.7.1
prosemirror-view: prosemirror-view:
specifier: ~1.29.2 specifier: ^1.31.7
version: 1.29.2 version: 1.31.7
svelte: svelte:
specifier: ^3.58.0 specifier: ^3.58.0
version: 3.58.0 version: 3.58.0
@ -330,6 +333,23 @@ packages:
dev: true dev: true
optional: true optional: true
/@floating-ui/core@1.4.1:
resolution: {integrity: sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==}
dependencies:
'@floating-ui/utils': 0.1.1
dev: false
/@floating-ui/dom@1.5.1:
resolution: {integrity: sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==}
dependencies:
'@floating-ui/core': 1.4.1
'@floating-ui/utils': 0.1.1
dev: false
/@floating-ui/utils@0.1.1:
resolution: {integrity: sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==}
dev: false
/@jridgewell/gen-mapping@0.3.3: /@jridgewell/gen-mapping@0.3.3:
resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@ -575,7 +595,7 @@ packages:
normalize-path: 3.0.0 normalize-path: 3.0.0
readdirp: 3.6.0 readdirp: 3.6.0
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.3
dev: true dev: true
/commander@4.1.1: /commander@4.1.1:
@ -776,8 +796,8 @@ packages:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true dev: true
/fsevents@2.3.2: /fsevents@2.3.3:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
requiresBuild: true requiresBuild: true
@ -1287,7 +1307,7 @@ packages:
dependencies: dependencies:
prosemirror-state: 1.4.2 prosemirror-state: 1.4.2
prosemirror-transform: 1.7.1 prosemirror-transform: 1.7.1
prosemirror-view: 1.29.2 prosemirror-view: 1.31.7
dev: true dev: true
/prosemirror-gapcursor@1.3.1: /prosemirror-gapcursor@1.3.1:
@ -1296,7 +1316,7 @@ packages:
prosemirror-keymap: 1.2.1 prosemirror-keymap: 1.2.1
prosemirror-model: 1.18.3 prosemirror-model: 1.18.3
prosemirror-state: 1.4.2 prosemirror-state: 1.4.2
prosemirror-view: 1.29.2 prosemirror-view: 1.31.7
dev: true dev: true
/prosemirror-history@1.3.0: /prosemirror-history@1.3.0:
@ -1351,15 +1371,15 @@ packages:
dependencies: dependencies:
prosemirror-model: 1.18.3 prosemirror-model: 1.18.3
prosemirror-transform: 1.7.1 prosemirror-transform: 1.7.1
prosemirror-view: 1.29.2 prosemirror-view: 1.31.7
/prosemirror-transform@1.7.1: /prosemirror-transform@1.7.1:
resolution: {integrity: sha512-VteoifAfpt46z0yEt6Fc73A5OID9t/y2QIeR5MgxEwTuitadEunD/V0c9jQW8ziT8pbFM54uTzRLJ/nLuQjMxg==} resolution: {integrity: sha512-VteoifAfpt46z0yEt6Fc73A5OID9t/y2QIeR5MgxEwTuitadEunD/V0c9jQW8ziT8pbFM54uTzRLJ/nLuQjMxg==}
dependencies: dependencies:
prosemirror-model: 1.18.3 prosemirror-model: 1.18.3
/prosemirror-view@1.29.2: /prosemirror-view@1.31.7:
resolution: {integrity: sha512-T4Wm+eTpTH0N9gBJfJR6iecjRX2hYTKewoJUwa92hQOoEz2bYVZy6sYeN+hfnRR506TRvRcuZYqftp4KA8dN+Q==} resolution: {integrity: sha512-Pr7w93yOYmxQwzGIRSaNLZ/1uM6YjnenASzN2H6fO6kGekuzRbgZ/4bHbBTd1u4sIQmL33/TcGmzxxidyPwCjg==}
dependencies: dependencies:
prosemirror-model: 1.18.3 prosemirror-model: 1.18.3
prosemirror-state: 1.4.2 prosemirror-state: 1.4.2
@ -1437,7 +1457,7 @@ packages:
engines: {node: '>=14.18.0', npm: '>=8.0.0'} engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.3
dev: true dev: true
/rope-sequence@1.3.3: /rope-sequence@1.3.3:
@ -1777,7 +1797,7 @@ packages:
resolve: 1.22.2 resolve: 1.22.2
rollup: 3.20.6 rollup: 3.20.6
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.3
dev: true dev: true
/vitefu@0.2.4(vite@4.2.2): /vitefu@0.2.4(vite@4.2.2):
@ -1823,7 +1843,7 @@ packages:
yjs: 13.5.52 yjs: 13.5.52
dev: false dev: false
/y-prosemirror@1.2.1(prosemirror-model@1.18.3)(prosemirror-state@1.4.2)(prosemirror-view@1.29.2)(y-protocols@1.0.5)(yjs@13.5.52): /y-prosemirror@1.2.1(prosemirror-model@1.18.3)(prosemirror-state@1.4.2)(prosemirror-view@1.31.7)(y-protocols@1.0.5)(yjs@13.5.52):
resolution: {integrity: sha512-czMBfB1eL2awqmOSxQM8cS/fsUOGE6fjvyPLInrh4crPxFiw67wDpwIW+EGBYKRa04sYbS0ScGj7ZgvWuDrmBQ==} resolution: {integrity: sha512-czMBfB1eL2awqmOSxQM8cS/fsUOGE6fjvyPLInrh4crPxFiw67wDpwIW+EGBYKRa04sYbS0ScGj7ZgvWuDrmBQ==}
peerDependencies: peerDependencies:
prosemirror-model: ^1.7.1 prosemirror-model: ^1.7.1
@ -1835,7 +1855,7 @@ packages:
lib0: 0.2.73 lib0: 0.2.73
prosemirror-model: 1.18.3 prosemirror-model: 1.18.3
prosemirror-state: 1.4.2 prosemirror-state: 1.4.2
prosemirror-view: 1.29.2 prosemirror-view: 1.31.7
y-protocols: 1.0.5 y-protocols: 1.0.5
yjs: 13.5.52 yjs: 13.5.52
dev: false dev: false

View file

@ -48,3 +48,13 @@ a {
--accent-fg: #94a3b8; --accent-fg: #94a3b8;
} }
} }
/* https://devdojo.com/pines/docs/button */
.btn {
@apply rounded-md bg-neutral-950 px-4 py-2 text-sm font-medium tracking-wide text-white dark:bg-neutral-200 dark:text-neutral-800;
}
.btn-outline {
@apply rounded-md border border-neutral-200/70 bg-white px-4 py-2 text-sm font-medium tracking-wide text-neutral-500 dark:bg-neutral-900 dark:text-neutral-200;
}
.input {
@apply flex h-10 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm placeholder:text-neutral-500 focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700/70 dark:bg-neutral-900;
}

View file

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount, tick } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import type { Writable } from "svelte/store";
import type { EditorView } from "prosemirror-view"; import type { EditorView } from "prosemirror-view";
import { EditorState, TextSelection } from "prosemirror-state"; import type { EditorState } from "prosemirror-state";
import BoldIcon from "bootstrap-icons/icons/type-bold.svg"; import BoldIcon from "bootstrap-icons/icons/type-bold.svg";
import ItalicIcon from "bootstrap-icons/icons/type-italic.svg"; import ItalicIcon from "bootstrap-icons/icons/type-italic.svg";
@ -9,7 +10,6 @@
import StrikethroughIcon from "bootstrap-icons/icons/type-strikethrough.svg"; import StrikethroughIcon from "bootstrap-icons/icons/type-strikethrough.svg";
import LinkIcon from "eva-icons/outline/svg/external-link-outline.svg"; import LinkIcon from "eva-icons/outline/svg/external-link-outline.svg";
import InternalLinkIcon from "eva-icons/outline/svg/menu-arrow-outline.svg"; import InternalLinkIcon from "eva-icons/outline/svg/menu-arrow-outline.svg";
import CloseIcon from "eva-icons/outline/svg/close-outline.svg";
import type { Command } from "./ps-utils"; import type { Command } from "./ps-utils";
import { import {
@ -17,6 +17,7 @@
removeMark, removeMark,
markIsActive, markIsActive,
getFirstMarkInSelection, getFirstMarkInSelection,
selectMark,
} from "./ps-utils"; } from "./ps-utils";
import SimpleMarkItem from "./bubblemenu/SimpleMarkItem.svelte"; import SimpleMarkItem from "./bubblemenu/SimpleMarkItem.svelte";
import Button from "./bubblemenu/Button.svelte"; import Button from "./bubblemenu/Button.svelte";
@ -28,6 +29,7 @@
export let view: EditorView; export let view: EditorView;
export let state: EditorState; export let state: EditorState;
export let worldY: WorldY; export let worldY: WorldY;
export let editingLink: Writable<false | "new" | "selection">;
let changingProp: let changingProp:
| false | false
@ -55,36 +57,19 @@
} }
function startEditingLink() { function startEditingLink() {
const match = getFirstMarkInSelection(state, view.state.schema.marks.link); const match = getFirstMarkInSelection(
view.state,
// si no hay un link en la selección, empezar a editar uno sin ningún enlace view.state.schema.marks.link,
// 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 }; if (match) {
} selectMark(match, view.state, view.dispatch);
$editingLink = "selection";
function removeLink() { } else if (!view.state.selection.empty) {
changingProp = false; $editingLink = "selection";
} else {
runCommand(removeMark(view.state.schema.marks.link)); runCommand(removeMark(view.state.schema.marks.link));
$editingLink = "new";
} }
function onChangeLink(event: Event) {
changingProp = false;
const url = (event.target as HTMLInputElement).value;
runCommand(updateMark(view.state.schema.marks.link, { href: url }));
} }
let makingInternalLink = false; let makingInternalLink = false;
@ -134,7 +119,7 @@ transform: scale(${1 / viewport.scale});
</Modal> </Modal>
{/if} {/if}
<div class="floating" style={barStyle}> <div class="floating z-40" style={barStyle}>
<Linking {state} /> <Linking {state} />
<div class="bubble" hidden={state.selection.empty}> <div class="bubble" hidden={state.selection.empty}>
{#if changingProp === false} {#if changingProp === false}
@ -162,17 +147,6 @@ transform: scale(${1 / viewport.scale});
onClick={startMakingInternalLink} onClick={startMakingInternalLink}
><InternalLinkIcon style={svgStyle} /></Button ><InternalLinkIcon style={svgStyle} /></Button
> >
{:else if changingProp.type === "link"}
<input
bind:this={linkInputEl}
type="text"
placeholder="https://"
on:change|preventDefault={onChangeLink}
value={changingProp.url}
/>
<Button title="Borrar enlace" onClick={removeLink}
><CloseIcon style={svgStyle} /></Button
>
{/if} {/if}
</div> </div>
</div> </div>
@ -202,19 +176,14 @@ transform: scale(${1 / viewport.scale});
opacity: 1; opacity: 1;
height: auto; height: auto;
transition: opacity 0.2s, visibility 0.2s, height 0.2s; transition:
opacity 0.2s,
visibility 0.2s,
height 0.2s;
} }
.bubble[hidden] { .bubble[hidden] {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
height: 0; height: 0;
} }
.bubble input {
appearance: none;
background: none;
color: inherit;
border: none;
font-size: 1.25em;
}
</style> </style>

View file

@ -16,12 +16,17 @@
// import { placeholderPlugin } from "./upload"; // import { placeholderPlugin } from "./upload";
import { baseKeymap } from "./keymap"; import { baseKeymap } from "./keymap";
import type { WorldY } from "../lib/doc"; import type { WorldY } from "../lib/doc";
import { writable } from "svelte/store";
import EditLinkMenu from "./bubblemenu/EditLinkMenu.svelte";
import LinkTooltip from "./bubblemenu/LinkTooltip.svelte";
export let doc: XmlFragment; export let doc: XmlFragment;
export let worldY: WorldY; export let worldY: WorldY;
let wrapperEl: HTMLElement; let wrapperEl: HTMLElement;
const editingLink = writable<false | "new" | "selection">(false);
function createState(doc: XmlFragment): EditorState { function createState(doc: XmlFragment): EditorState {
return EditorState.create({ return EditorState.create({
schema, schema,
@ -69,11 +74,16 @@
<div class="editor min-h-screen"> <div class="editor min-h-screen">
{#if view} {#if view}
<MenuBar {view} state={updatedState} /> <MenuBar {view} state={updatedState} />
<EditLinkMenu state={updatedState} {view} {editingLink} />
<LinkTooltip state={updatedState} {view} {editingLink} />
{/if} {/if}
<!-- this element gets replaced with the editor itself when mounted --> <!-- this element gets replaced with the editor itself when mounted -->
<div class="prose dark:prose-invert before:prose-p:content-none after:prose-p:content-none prose-blockquote:font-normal prose-blockquote:not-italic max-w-none min-h-screen" bind:this={wrapperEl} /> <div
class="prose min-h-screen max-w-none dark:prose-invert before:prose-p:content-none after:prose-p:content-none prose-blockquote:font-normal prose-blockquote:not-italic"
bind:this={wrapperEl}
/>
{#if view} {#if view}
<BubbleMenu {view} {worldY} state={updatedState} /> <BubbleMenu {view} {worldY} {editingLink} state={updatedState} />
{/if} {/if}
</div> </div>

View file

@ -12,7 +12,9 @@
export let state: EditorState; export let state: EditorState;
</script> </script>
<div class="menubar sticky top-0 flex py-2 px-4 z-50 items-center"> <div
class="sticky top-0 z-50 flex items-center border-b border-neutral-200/60 bg-white px-4 py-2 dark:border-neutral-700 dark:bg-neutral-800"
>
<BlockSelect {view} {state} /> <BlockSelect {view} {state} />
<!-- <UploadItem {view} {state} /> --> <!-- <UploadItem {view} {state} /> -->
<ListItem {view} {state} kind={ListKind.Unordered} /> <ListItem {view} {state} kind={ListKind.Unordered} />
@ -20,9 +22,14 @@
<BlockQuoteItem {view} {state} /> <BlockQuoteItem {view} {state} />
</div> </div>
<style> <style lang="postcss">
.menubar { div :global(button) {
background: var(--background, white); @apply m-1 appearance-none rounded p-2 leading-none transition-colors hover:bg-neutral-600/20 dark:hover:bg-neutral-600/40;
border-bottom: 1px solid var(--accent-bg); }
div :global(button.active) {
@apply bg-neutral-300 dark:bg-neutral-700;
}
div :global(button svg) {
@apply h-6 w-6;
} }
</style> </style>

View file

@ -0,0 +1,120 @@
<script lang="ts">
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { getFirstMarkInSelection } from "../ps-utils";
import { readable, type Writable } from "svelte/store";
import { nanoid } from "nanoid";
import { linkFloatingUi, selectionFloatingUi } from "./floatingUi";
import {
autoPlacement,
shift,
offset,
type ComputePositionConfig,
} from "@floating-ui/dom";
export let state: EditorState;
export let view: EditorView;
export let editingLink: Writable<false | "new" | "selection">;
let formEl: HTMLFormElement;
// ref: https://gitlab.com/gitlab-org/gitlab-foss/-/blob/13d851c795a48b670b859a7ec5bd6e2886d2789e/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue#L69
let text = "";
let href = "";
function onChangeEditingLink(editingLink: false | "new" | "selection") {
if (!editingLink) return;
text = "";
href = "";
if (editingLink === "selection") {
// https://gitlab.com/gitlab-org/gitlab-foss/-/blob/13d851c795a48b670b859a7ec5bd6e2886d2789e/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue#L69
text = getSelectionText(view.state);
const match = getFirstMarkInSelection(
view.state,
view.state.schema.marks.link,
);
if (!match) return;
href = match.mark.attrs.href;
}
}
$: onChangeEditingLink($editingLink);
function onSubmit() {
const { from, to } = view.state.selection;
const mark = view.state.schema.marks.link.create({ href });
if ($editingLink === "new") {
view.dispatch(
view.state.tr
.ensureMarks([mark])
.insertText(text, from, from)
.removeStoredMark(mark),
);
} else if ($editingLink === "selection") {
let tr = view.state.tr;
tr.addMark(from, to, mark);
const currentText = getSelectionText(view.state);
if (currentText !== text)
tr.ensureMarks([mark])
.insertText(text, from, to)
.removeStoredMark(mark);
view.dispatch(tr);
}
$editingLink = false;
}
function cancel() {
$editingLink = false;
}
function detectFocus(event: any) {
if (!formEl.contains(event.target)) cancel();
}
function getSelectionText(state: EditorState) {
return state.doc.textBetween(state.selection.from, state.selection.to, " ");
}
const id = nanoid();
$: shown = !!$editingLink;
$: style = shown
? selectionFloatingUi(formEl, {
placement: "top",
middleware: [offset(6), autoPlacement(), shift({ padding: 5 })],
})
: readable("");
</script>
<svelte:document on:pointerdown={detectFocus} />
<form
class="absolute z-50 w-max rounded-lg border border-neutral-200/70 bg-white p-4 shadow-lg dark:border-neutral-700 dark:bg-neutral-900"
style={$style}
hidden={!shown}
bind:this={formEl}
on:submit|preventDefault={onSubmit}
>
<div class="mb-4">
<label for={`link-text-${id}`} class="block">Texto</label>
<input class="input" id={`link-text-${id}`} type="text" bind:value={text} />
</div>
<div class="mb-4">
<label for={`link-href-${id}`} class="block">Enlace</label>
<input
class="input"
id={`link-href-${id}`}
type="url"
autofocus
bind:value={href}
/>
</div>
<div class="flex justify-end gap-2">
<button class="btn-outline" type="button" on:click={cancel}>Cancelar</button
>
<button class="btn" type="submit">Guardar</button>
</div>
</form>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import type { EditorState } from "prosemirror-state";
import {
getFirstMarkInSelection,
getMarkRange,
selectMark,
} from "../ps-utils";
import type { EditorView } from "prosemirror-view";
import { linkFloatingUi, selectionFloatingUi } from "./floatingUi";
import { readable, type Writable } from "svelte/store";
import { flip, shift, offset } from "@floating-ui/dom";
import EditIcon from "eva-icons/outline/svg/edit-outline.svg";
import CloseIcon from "eva-icons/fill/svg/close.svg";
export let state: EditorState;
export let view: EditorView;
export let editingLink: Writable<false | "new" | "selection">;
let tooltipEl: HTMLElement;
$: markType = view.state.schema.marks.link;
$: link = state && getFirstMarkInSelection(view.state, markType);
$: shown = !!link && !$editingLink;
$: style = shown
? linkFloatingUi(view, tooltipEl, {
placement: "bottom",
middleware: [offset(6), flip(), shift({ padding: 5 })],
})
: readable("");
function editLink() {
if (!link) return;
selectMark(link, view.state, view.dispatch);
$editingLink = "selection";
}
function removeLink() {
const range = getMarkRange(state.selection.$from, markType);
if (!range)
throw new Error("removeLink: Couldn't get mark range to remove");
view.dispatch(state.tr.removeMark(range.from, range.to, markType));
}
</script>
<div
class="absolute z-20 w-max items-center overflow-hidden rounded border border-neutral-200/70 bg-white px-1 leading-none shadow-lg dark:border-neutral-700 dark:bg-neutral-900"
class:flex={shown}
class:hidden={!shown}
bind:this={tooltipEl}
style={$style}
>
<span class=" ">
<a
class="block max-w-[50vw] overflow-x-hidden text-ellipsis whitespace-nowrap p-2 text-blue-500 dark:text-blue-300"
href={link?.mark.attrs.href}
rel="noopener noreferrer nofollow"
target="_blank"
>
{link?.mark.attrs.href}
</a>
</span>
<button
class="my-1 appearance-none rounded border-none p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700"
type="button"
on:click={editLink}
title={"Editar enlace"}
>
<EditIcon class="h-6 w-6 fill-current" />
</button>
<button
class="my-1 appearance-none rounded border-none p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700"
type="button"
on:click={removeLink}
title={"Borrar enlace"}
>
<CloseIcon class="h-6 w-6 fill-current" />
</button>
</div>

View file

@ -0,0 +1,54 @@
import { readable, type Readable } from "svelte/store";
import { computePosition, autoUpdate } from "@floating-ui/dom";
import type {
ComputePositionConfig,
ReferenceElement,
} from "@floating-ui/dom/src/types";
import type { EditorView } from "prosemirror-view";
import { getFirstMarkInSelection } from "../ps-utils";
export type Style = string;
export function floatingUi(
refEl: ReferenceElement,
tooltipEl: HTMLElement,
options?: Partial<ComputePositionConfig>,
): Readable<Style> {
return {
subscribe(run, invalidate) {
return autoUpdate(refEl, tooltipEl, () => {
computePosition(refEl, tooltipEl, options).then(({ x, y }) => {
run(`left: ${x}px; top: ${y}px`);
});
});
},
};
}
export function selectionFloatingUi(
tooltipEl: HTMLElement,
options?: Partial<ComputePositionConfig>,
): Readable<Style> {
const sel = document.getSelection();
const range = sel?.getRangeAt(0);
if (!range) return readable("");
return floatingUi(range, tooltipEl, options);
}
export function linkFloatingUi(
view: EditorView,
tooltipEl: HTMLElement,
options?: Partial<ComputePositionConfig>,
): Readable<Style> {
const mark = getFirstMarkInSelection(
view.state,
view.state.schema.marks.link,
);
if (!mark) return readable("");
let node = view.nodeDOM(mark?.position);
if (!node) return readable("");
const element = node instanceof Element ? node : node.parentElement;
if (!element) return readable("");
return floatingUi(element, tooltipEl, options);
}

View file

@ -4,36 +4,6 @@
position: relative; position: relative;
} }
.editor *::before,
.editor *::after {
box-sizing: border-box;
}
.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;
color: var(--foreground);
}
.editor .menubar button:hover {
background: var(--transparentish);
}
.editor .menubar button.active {
background: var(--accent-bg);
}
.editor .menubar button svg {
width: 1.5rem;
height: 1.5rem;
}
.ProseMirror { .ProseMirror {
width: 100%; width: 100%;
padding: 0.5em; padding: 0.5em;

View file

@ -79,7 +79,7 @@
<div class="flex min-w-min"> <div class="flex min-w-min">
{#each links as link} {#each links as link}
<a <a
class="m-1 flex max-w-[45vw] items-center gap-1 rounded-full border border-neutral-200 bg-white px-4 py-3 no-underline dark:border-neutral-500 dark:bg-neutral-800" class="m-1 flex max-w-[45vw] items-center gap-1 rounded-full border border-neutral-200 bg-white px-4 py-3 no-underline dark:border-neutral-700/70 dark:bg-neutral-800"
href={"href" in link ? link.href : link.id} href={"href" in link ? link.href : link.id}
target={link.type === "external" ? "_blank" : null} target={link.type === "external" ? "_blank" : null}
> >

View file

@ -6,7 +6,12 @@ import type {
ResolvedPos, ResolvedPos,
Node as ProsemirrorNode, Node as ProsemirrorNode,
} from "prosemirror-model"; } from "prosemirror-model";
import type { EditorState } from "prosemirror-state"; import {
EditorState,
Selection,
TextSelection,
Transaction,
} from "prosemirror-state";
import type { EditorView } from "prosemirror-view"; import type { EditorView } from "prosemirror-view";
export type Command = ( export type Command = (
@ -66,15 +71,15 @@ export function updateMark(type: MarkType, attrs: any): Command {
const { ranges, empty } = selection; const { ranges, empty } = selection;
if (empty) { if (empty) {
if (doc.rangeHasMark(selection.from, selection.to, type)) {
const range = getMarkRange(selection.$from, type); const range = getMarkRange(selection.$from, type);
if (!range) throw new Error("What the fuck"); if (!range) throw new Error("What the fuck");
const { from, to } = range; const { from, to } = range;
if (doc.rangeHasMark(from, to, type)) {
tr.removeMark(from, to, type); tr.removeMark(from, to, type);
} else {
tr.addStoredMark(type.create(attrs));
} }
tr.addMark(from, to, type.create(attrs));
} else { } else {
ranges.forEach((ref$1) => { ranges.forEach((ref$1) => {
const { $to, $from } = ref$1; const { $to, $from } = ref$1;
@ -174,7 +179,7 @@ export function getAttrFn(attrKey: string): (state: EditorState) => any {
return (state) => { return (state) => {
let { from, to } = state.selection; let { from, to } = state.selection;
let value: any = undefined; let value: any = undefined;
state.doc.nodesBetween(from, to, (node) => { state.doc.nodesBetween(from, to, (node, pos) => {
if (value !== undefined) return false; if (value !== undefined) return false;
if (!node.isTextblock) return; if (!node.isTextblock) return;
if (attrKey in node.attrs) value = node.attrs[attrKey]; if (attrKey in node.attrs) value = node.attrs[attrKey];
@ -183,9 +188,63 @@ export function getAttrFn(attrKey: string): (state: EditorState) => any {
}; };
} }
// Adaptado de
// https://github.com/ueberdosis/tiptap/blob/8c6751f0c638effb22110b62b40a1632ea6867c9/packages/core/src/helpers/isMarkActive.ts#L18
export function markIsActive(state: EditorState, type: MarkType): boolean { export function markIsActive(state: EditorState, type: MarkType): boolean {
let { from, to } = state.selection; const { empty, ranges } = state.selection;
return state.doc.rangeHasMark(from, to, type);
if (empty) {
return !!(state.storedMarks || state.selection.$from.marks()).some(
(mark) => type.name === mark.type.name,
);
}
let selectionRange = 0;
const markRanges: {
mark: Mark;
from: number;
to: number;
}[] = [];
ranges.forEach(({ $from, $to }) => {
const from = $from.pos;
const to = $to.pos;
state.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isText && !node.marks.length) return;
const relativeFrom = Math.max(from, pos);
const relativeTo = Math.min(to, pos + node.nodeSize);
const range = relativeTo - relativeFrom;
selectionRange += range;
markRanges.push(
...node.marks.map((mark) => ({
mark,
from: relativeFrom,
to: relativeTo,
})),
);
});
});
if (selectionRange === 0) return false;
const matchedRange = markRanges
.filter((markRange) => type.name === markRange.mark.type.name)
.reduce((sum, markRange) => sum + markRange.to - markRange.from, 0);
const excludedRange = markRanges
.filter(
(markRange) =>
markRange.mark.type !== type && markRange.mark.type.excludes(type),
)
.reduce((sum, markRange) => sum + markRange.to - markRange.from, 0);
const range = matchedRange > 0 ? matchedRange + excludedRange : matchedRange;
return range >= selectionRange;
} }
export interface MarkMatch { export interface MarkMatch {
@ -198,16 +257,34 @@ export function getFirstMarkInSelection(
state: EditorState, state: EditorState,
type: MarkType, type: MarkType,
): MarkMatch | null { ): MarkMatch | null {
const { to, from } = state.selection; const { to, from, empty } = state.selection;
let match: MarkMatch | null = null; let match: MarkMatch | null = null;
state.selection.$from.doc.nodesBetween(from, to, (node, position) => { state.selection.$from.doc.nodesBetween(from, to, (node, position) => {
if (!match) { if (!match) {
const mark = type.isInSet(node.marks); const mark = type.isInSet(node.marks);
if (!mark) return; if (!mark) return;
if (!markIsActive(state, type)) return;
match = { node, position, mark }; match = { node, position, mark };
} }
}); });
return match; return match;
} }
export function selectMark(
mark: MarkMatch,
state: EditorState,
dispatch: EditorView["dispatch"],
) {
dispatch(
state.tr.setSelection(
TextSelection.create(
state.doc,
mark.position,
mark.position + mark.node.nodeSize,
),
),
);
}

View file

@ -35,7 +35,7 @@
} }
</script> </script>
<nav> <nav class="px-4 py-2">
<details> <details>
<summary>Opciones</summary> <summary>Opciones</summary>
<ul> <ul>

View file

@ -7,6 +7,7 @@
import { derived } from "svelte/store"; import { derived } from "svelte/store";
import { getTitle } from "../../lib/getTitle"; import { getTitle } from "../../lib/getTitle";
import { routes } from "../../lib/routes"; import { routes } from "../../lib/routes";
import type { HTMLOlAttributes } from "svelte/elements";
export let worldId: string; export let worldId: string;
export let pageId: string; export let pageId: string;
@ -27,22 +28,27 @@
); );
let breadcrumbsEl: HTMLDivElement; let breadcrumbsEl: HTMLDivElement;
const crumbsScrollToEnd = async () => { let breadcrumbsListEl: HTMLOListElement;
const crumbsScrollToEnd = async (behavior: ScrollBehavior = "auto") => {
await tick(); await tick();
breadcrumbsEl?.scroll({ breadcrumbsEl.scroll({
left: breadcrumbsEl.scrollWidth, left: breadcrumbsEl.scrollWidth,
behavior: "smooth", behavior,
}); });
}; };
onMount(() => { onMount(() => {
crumbsScrollToEnd(); crumbsScrollToEnd("smooth");
const resizeObserver = new ResizeObserver(() =>
crumbsScrollToEnd("smooth"),
);
resizeObserver.observe(breadcrumbsListEl);
return () => resizeObserver.disconnect();
});
onMount(() => {
const resizeObserver = new ResizeObserver(() => crumbsScrollToEnd());
resizeObserver.observe(breadcrumbsEl);
return () => resizeObserver.disconnect();
}); });
$: {
$crumbsTitles;
$pageBreadcrumbs;
crumbsScrollToEnd();
}
</script> </script>
<!-- https://devdojo.com/pines/docs/breadcrumbs --> <!-- https://devdojo.com/pines/docs/breadcrumbs -->
@ -52,6 +58,7 @@
> >
<ol <ol
class="inline-flex items-center space-x-1 text-xs text-neutral-500 dark:text-neutral-300 sm:mb-0 [&_.active-breadcrumb]:font-medium [&_.active-breadcrumb]:text-neutral-600 dark:[&_.active-breadcrumb]:text-neutral-200" class="inline-flex items-center space-x-1 text-xs text-neutral-500 dark:text-neutral-300 sm:mb-0 [&_.active-breadcrumb]:font-medium [&_.active-breadcrumb]:text-neutral-600 dark:[&_.active-breadcrumb]:text-neutral-200"
bind:this={breadcrumbsListEl}
> >
{#each $pageBreadcrumbs as crumb, index} {#each $pageBreadcrumbs as crumb, index}
<li> <li>