traer cambios de enlaces de sutty/editor

This commit is contained in:
Cat /dev/Nulo 2023-08-31 10:36:21 -03:00
parent 26714cb341
commit 91cbf2260b
7 changed files with 406 additions and 12 deletions

View file

@ -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
@ -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'}

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,9 +74,14 @@
<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} state={updatedState} />
{/if} {/if}

View file

@ -0,0 +1,142 @@
<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 { markSelectionFloatingUi } from "./floatingUi";
import { autoPlacement, shift, offset } from "@floating-ui/dom";
export let state: EditorState;
export let view: EditorView;
export let editingLink: Writable<false | "new" | "selection">;
let hrefInputEl: HTMLInputElement;
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 = "";
// el timeout... ugh
hrefInputEl?.focus();
setTimeout(() => hrefInputEl?.focus(), 50);
setTimeout(() => hrefInputEl?.focus(), 100);
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;
$: linkMatch =
state && getFirstMarkInSelection(view.state, view.state.schema.marks.link);
$: style =
shown && linkMatch
? markSelectionFloatingUi(view, linkMatch, formEl, {
placement: "top",
middleware: [offset(6), autoPlacement(), shift({ padding: 5 })],
})
: readable("");
</script>
<svelte:document on:pointerdown={detectFocus} />
<form
class="rounded-lg bg-white shadow-lg"
style={$style}
hidden={!shown}
bind:this={formEl}
on:submit|preventDefault={onSubmit}
>
<div class="form-group">
<label for={`link-text-${id}`} class="col-form-label d-block">Texto</label>
<input
class="form-control"
id={`link-text-${id}`}
type="text"
bind:value={text}
/>
</div>
<div class="form-group was-validated">
<label for={`link-href-${id}`} class="col-form-label d-block">Enlace</label>
<input
class="form-control"
id={`link-href-${id}`}
type="url"
bind:this={hrefInputEl}
bind:value={href}
/>
</div>
<div class="d-flex justify-content-end buttons">
<button type="button" class="btn btn-secondary" on:click={cancel}
>Cancelar</button
>
<button type="submit" class="btn btn-primary">Guardar</button>
</div>
</form>
<style>
form {
width: max-content;
position: absolute;
z-index: 420;
padding: 1rem;
}
.buttons {
gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,103 @@
<script lang="ts">
import type { EditorState } from "prosemirror-state";
import {
getFirstMarkInSelection,
getMarkRange,
removeMark,
selectMark,
} from "../ps-utils";
import type { EditorView } from "prosemirror-view";
import { markSelectionFloatingUi } from "./floatingUi";
import { readable, type Writable } from "svelte/store";
import { flip, shift, offset } from "@floating-ui/dom";
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 && link
? markSelectionFloatingUi(view, link, 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="x-tooltip bg-gray"
class:flex={shown}
class:hidden={!shown}
bind:this={tooltipEl}
style={$style}
>
<!-- TODO: hacer que el clickeable sea relativo a la url final del sitio? -->
<span>
<a
href={link?.mark.attrs.href}
rel="noopener noreferrer nofollow"
target="_blank"
>
{link?.mark.attrs.href}
</a>
</span>
<button type="button" on:click={editLink}>
<i class={`fa fa-edit`} title={"Editar enlace"} />
</button>
<button type="button" on:click={removeLink}>
<i class={`fa fa-remove`} title={"Borrar enlace"} />
</button>
</div>
<style>
.x-tooltip {
width: max-content;
position: absolute;
background: #222;
color: white;
border-radius: 4px;
z-index: 420;
line-height: 1;
align-items: center;
}
.x-tooltip > * {
padding: 0.4rem 0.5rem;
}
a {
display: block;
color: lightblue;
max-width: 50vw;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
button {
background: #222;
color: white;
border: none;
appearance: none;
font-size: 1.5rem;
border-radius: 4px;
}
button:hover {
background: #444;
}
</style>

View file

@ -0,0 +1,41 @@
import { readable, type Readable } from "svelte/store";
import {
computePosition,
autoUpdate,
autoPlacement,
shift,
offset,
} from "@floating-ui/dom";
import type { ComputePositionConfig } from "@floating-ui/dom/src/types";
import type { MarkMatch } from "../ps-utils";
import type { EditorView } from "prosemirror-view";
export type Style = string;
export function floatingUi(
refEl: Element,
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 markSelectionFloatingUi(
view: EditorView,
mark: MarkMatch,
tooltipEl: HTMLElement,
options?: Partial<ComputePositionConfig>,
): Readable<Style> {
let { node } = (view as any).docView.domFromPos(view.state.selection.from);
if (!node || !mark || !tooltipEl) return readable("");
if (!(node instanceof Element)) node = node.parentElement;
return floatingUi(node, tooltipEl, options);
}

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) {
const range = getMarkRange(selection.$from, type); if (doc.rangeHasMark(selection.from, selection.to, type)) {
if (!range) throw new Error("What the fuck"); const range = getMarkRange(selection.$from, type);
const { from, to } = range; if (!range) throw new Error("What the fuck");
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,
),
),
);
}