@ecsplico/editor 2.1.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/dist/adapters/comments/client.js +2 -2
- package/dist/adapters/comments/client.js.map +1 -1
- package/dist/adapters/comments/drizzle.js +9 -9
- package/dist/adapters/comments/drizzle.js.map +1 -1
- package/dist/adapters/comments/errors.js +1 -1
- package/dist/adapters/comments/errors.js.map +1 -1
- package/dist/adapters/comments/record.js +11 -11
- package/dist/adapters/comments/record.js.map +1 -1
- package/dist/adapters/comments/server.js +10 -10
- package/dist/adapters/comments/server.js.map +1 -1
- package/dist/adapters/vue.js +2 -2
- package/dist/adapters/vue.js.map +1 -1
- package/dist/features/block-ui/block-actions.d.ts +21 -0
- package/dist/features/block-ui/block-actions.js +25 -1
- package/dist/features/block-ui/block-actions.js.map +1 -1
- package/dist/features/block-ui/components/BlockActionsOverlay.js +33 -4
- package/dist/features/block-ui/components/BlockActionsOverlay.js.map +1 -1
- package/dist/features/block-ui/components/BlockActionsOverlay.vue.d.ts +4 -0
- package/dist/features/block-ui/index.d.ts +2 -2
- package/dist/features/block-ui/index.js +2 -2
- package/dist/features/block-ui/react/BlockActionsOverlay.d.ts +4 -0
- package/dist/features/block-ui/react/BlockActionsOverlay.js +38 -12
- package/dist/features/block-ui/react/BlockActionsOverlay.js.map +1 -1
- package/dist/features/comments/model.js.map +1 -1
- package/dist/markdown/MarkdownSurface.vue.d.ts +1 -1
- package/dist/tree/tree-host.js +1 -1
- package/dist/tree/tree-items.js +1 -1
- package/docs/integration.md +3 -1
- package/docs/llms.md +1 -1
- package/package.json +1 -1
|
@@ -5,12 +5,12 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
|
|
|
5
5
|
const isText = (value) => typeof value === "string";
|
|
6
6
|
const isId = (value) => isText(value) && value !== "";
|
|
7
7
|
function commentOf(value) {
|
|
8
|
-
if (!isRecord(value) || !isId(value.id) || !isText(value.body) || !isId(value.authorId) || !isId(value.createdAt)) throw invalid("
|
|
8
|
+
if (!isRecord(value) || !isId(value.id) || !isText(value.body) || !isId(value.authorId) || !isId(value.createdAt)) throw invalid("Ein Kommentar braucht id, body, authorId und createdAt");
|
|
9
9
|
for (const key of [
|
|
10
10
|
"authorName",
|
|
11
11
|
"authorColor",
|
|
12
12
|
"editedAt"
|
|
13
|
-
]) if (value[key] !== void 0 && !isText(value[key])) throw invalid(`
|
|
13
|
+
]) if (value[key] !== void 0 && !isText(value[key])) throw invalid(`Kommentar-Feld „${key}“ muss Text sein`);
|
|
14
14
|
const { id, body, authorId, createdAt } = value;
|
|
15
15
|
const { authorName, authorColor, editedAt } = value;
|
|
16
16
|
return {
|
|
@@ -24,7 +24,7 @@ function commentOf(value) {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
function annotationOf(value) {
|
|
27
|
-
if (!isRecord(value) || !isId(value.id) || !isId(value.threadId) || !isPortableAnchor(value.anchor)) throw invalid("
|
|
27
|
+
if (!isRecord(value) || !isId(value.id) || !isId(value.threadId) || !isPortableAnchor(value.anchor)) throw invalid("Eine Annotation braucht id, threadId und einen gültigen anchor");
|
|
28
28
|
return {
|
|
29
29
|
id: value.id,
|
|
30
30
|
threadId: value.threadId,
|
|
@@ -32,7 +32,7 @@ function annotationOf(value) {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
function threadInputOf(value) {
|
|
35
|
-
if (!isRecord(value) || !isId(value.id) || !isId(value.annotationId)) throw invalid("
|
|
35
|
+
if (!isRecord(value) || !isId(value.id) || !isId(value.annotationId)) throw invalid("Ein Thread braucht id, annotationId und initialComment");
|
|
36
36
|
return {
|
|
37
37
|
id: value.id,
|
|
38
38
|
annotationId: value.annotationId,
|
|
@@ -41,15 +41,15 @@ function threadInputOf(value) {
|
|
|
41
41
|
}
|
|
42
42
|
function idOf(value, key) {
|
|
43
43
|
const id = value[key];
|
|
44
|
-
if (!isId(id)) throw invalid(
|
|
44
|
+
if (!isId(id)) throw invalid(`„${key}“ muss ein nicht leerer String sein`);
|
|
45
45
|
return id;
|
|
46
46
|
}
|
|
47
47
|
function statusOf(value) {
|
|
48
|
-
if (value !== "open" && value !== "resolved") throw invalid("
|
|
48
|
+
if (value !== "open" && value !== "resolved") throw invalid("„status“ muss „open“ oder „resolved“ sein");
|
|
49
49
|
return value;
|
|
50
50
|
}
|
|
51
51
|
async function perform(store, documentId, body) {
|
|
52
|
-
if (!isRecord(body)) throw invalid("
|
|
52
|
+
if (!isRecord(body)) throw invalid("Der Request-Body muss ein JSON-Objekt mit „action“ sein");
|
|
53
53
|
switch (body.action) {
|
|
54
54
|
case "create": return store.createThread(documentId, {
|
|
55
55
|
annotation: annotationOf(body.annotation),
|
|
@@ -62,10 +62,10 @@ async function perform(store, documentId, body) {
|
|
|
62
62
|
await store.removeThread(documentId, idOf(body, "threadId"));
|
|
63
63
|
return { ok: true };
|
|
64
64
|
case "edit":
|
|
65
|
-
if (!isText(body.body)) throw invalid("
|
|
65
|
+
if (!isText(body.body)) throw invalid("„body“ muss Text sein");
|
|
66
66
|
return { thread: await store.editComment(documentId, idOf(body, "threadId"), idOf(body, "commentId"), body.body, isId(body.editedAt) ? body.editedAt : (/* @__PURE__ */ new Date()).toISOString()) };
|
|
67
67
|
case "remove-comment": return { thread: await store.removeComment(documentId, idOf(body, "threadId"), idOf(body, "commentId")) };
|
|
68
|
-
default: throw invalid("
|
|
68
|
+
default: throw invalid("Unbekannte Kommentar-Aktion");
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
@@ -86,7 +86,7 @@ async function handleCommentsRequest(store, request) {
|
|
|
86
86
|
};
|
|
87
87
|
return {
|
|
88
88
|
status: 405,
|
|
89
|
-
body: { error: `
|
|
89
|
+
body: { error: `Methode ${request.method} ist nicht erlaubt` }
|
|
90
90
|
};
|
|
91
91
|
} catch (error) {
|
|
92
92
|
if (error instanceof CommentStoreError) return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","names":[],"sources":["../../../src/adapters/comments/server.ts"],"sourcesContent":["import { isPortableAnchor } from '../anchors'\nimport { CommentStoreError, invalid } from './errors'\nimport type { Annotation, Comment, CommentStore, CreateThreadInput, ThreadStatus } from './types'\n\n// Framework-free REST handler of a document's comments endpoint: the host routes `GET`/`POST` of\n// `<base>/<documentId>` here after its own authentication and authorisation (the handler trusts the caller, including\n// the `authorId` of new comments), and answers with `status` and `body`. Nitro, Express or a test's fetch stub all fit.\n\nexport type { Annotation, Comment, CommentAction, CommentStore, CommentThread, CreateThreadInput, Discussion, NewThread, ThreadStatus } from './types'\nexport { CommentStoreError } from './errors'\n\nexport interface CommentsRequest {\n method: string\n documentId: string\n /** The parsed JSON body of a `POST`. */\n body?: unknown\n}\n\nexport interface CommentsResponse {\n status: number\n /** JSON; `{ error }` for a refused request. */\n body: unknown\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)\nconst isText = (value: unknown): value is string => typeof value === 'string'\nconst isId = (value: unknown): value is string => isText(value) && value !== ''\n\nfunction commentOf(value: unknown): Comment {\n if (!isRecord(value) || !isId(value.id) || !isText(value.body) || !isId(value.authorId) || !isId(value.createdAt)) throw invalid('
|
|
1
|
+
{"version":3,"file":"server.js","names":[],"sources":["../../../src/adapters/comments/server.ts"],"sourcesContent":["import { isPortableAnchor } from '../anchors'\nimport { CommentStoreError, invalid } from './errors'\nimport type { Annotation, Comment, CommentStore, CreateThreadInput, ThreadStatus } from './types'\n\n// Framework-free REST handler of a document's comments endpoint: the host routes `GET`/`POST` of\n// `<base>/<documentId>` here after its own authentication and authorisation (the handler trusts the caller, including\n// the `authorId` of new comments), and answers with `status` and `body`. Nitro, Express or a test's fetch stub all fit.\n\nexport type { Annotation, Comment, CommentAction, CommentStore, CommentThread, CreateThreadInput, Discussion, NewThread, ThreadStatus } from './types'\nexport { CommentStoreError } from './errors'\n\nexport interface CommentsRequest {\n method: string\n documentId: string\n /** The parsed JSON body of a `POST`. */\n body?: unknown\n}\n\nexport interface CommentsResponse {\n status: number\n /** JSON; `{ error }` for a refused request. */\n body: unknown\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)\nconst isText = (value: unknown): value is string => typeof value === 'string'\nconst isId = (value: unknown): value is string => isText(value) && value !== ''\n\nfunction commentOf(value: unknown): Comment {\n if (!isRecord(value) || !isId(value.id) || !isText(value.body) || !isId(value.authorId) || !isId(value.createdAt)) throw invalid('Ein Kommentar braucht id, body, authorId und createdAt')\n for (const key of ['authorName', 'authorColor', 'editedAt']) {\n if (value[key] !== undefined && !isText(value[key])) throw invalid(`Kommentar-Feld „${key}“ muss Text sein`)\n }\n const { id, body, authorId, createdAt } = value\n const { authorName, authorColor, editedAt } = value as Record<string, string | undefined>\n return { id, body, authorId, createdAt, ...(authorName !== undefined && { authorName }), ...(authorColor !== undefined && { authorColor }), ...(editedAt !== undefined && { editedAt }) }\n}\n\nfunction annotationOf(value: unknown): Annotation {\n if (!isRecord(value) || !isId(value.id) || !isId(value.threadId) || !isPortableAnchor(value.anchor)) throw invalid('Eine Annotation braucht id, threadId und einen gültigen anchor')\n return { id: value.id, threadId: value.threadId, anchor: value.anchor }\n}\n\nfunction threadInputOf(value: unknown): CreateThreadInput {\n if (!isRecord(value) || !isId(value.id) || !isId(value.annotationId)) throw invalid('Ein Thread braucht id, annotationId und initialComment')\n return { id: value.id, annotationId: value.annotationId, initialComment: commentOf(value.initialComment) }\n}\n\nfunction idOf(value: Record<string, unknown>, key: string): string {\n const id = value[key]\n if (!isId(id)) throw invalid(`„${key}“ muss ein nicht leerer String sein`)\n return id\n}\n\nfunction statusOf(value: unknown): ThreadStatus {\n if (value !== 'open' && value !== 'resolved') throw invalid('„status“ muss „open“ oder „resolved“ sein')\n return value\n}\n\nasync function perform(store: CommentStore, documentId: string, body: unknown): Promise<unknown> {\n if (!isRecord(body)) throw invalid('Der Request-Body muss ein JSON-Objekt mit „action“ sein')\n switch (body.action) {\n case 'create':\n return store.createThread(documentId, { annotation: annotationOf(body.annotation), thread: threadInputOf(body.thread) })\n case 'reply':\n return { thread: await store.addComment(documentId, idOf(body, 'threadId'), commentOf(body.comment)) }\n case 'status':\n return { thread: await store.setThreadStatus(documentId, idOf(body, 'threadId'), statusOf(body.status)) }\n case 'update-annotation':\n return { annotation: await store.updateAnchor(documentId, annotationOf(body.annotation)) }\n case 'remove':\n await store.removeThread(documentId, idOf(body, 'threadId'))\n return { ok: true }\n case 'edit':\n if (!isText(body.body)) throw invalid('„body“ muss Text sein')\n return { thread: await store.editComment(documentId, idOf(body, 'threadId'), idOf(body, 'commentId'), body.body, isId(body.editedAt) ? body.editedAt : new Date().toISOString()) }\n case 'remove-comment':\n return { thread: await store.removeComment(documentId, idOf(body, 'threadId'), idOf(body, 'commentId')) }\n default:\n throw invalid('Unbekannte Kommentar-Aktion')\n }\n}\n\n/**\n * `GET` → `200` with the document's `Discussion`; `POST` with a `CommentAction` → `200` with the changed `thread`/`annotation`\n * (`create`: both; `remove`: `{ ok: true }`). Refused requests answer `404` (unknown ids), `409` (duplicate ids), `400`\n * (malformed input) or `405` with `{ error }`; anything else is a server fault and is thrown.\n */\nexport async function handleCommentsRequest(store: CommentStore, request: CommentsRequest): Promise<CommentsResponse> {\n const method = request.method.toUpperCase()\n try {\n if (method === 'GET') return { status: 200, body: await store.getDiscussion(request.documentId) }\n if (method === 'POST') return { status: 200, body: await perform(store, request.documentId, request.body) }\n return { status: 405, body: { error: `Methode ${request.method} ist nicht erlaubt` } } // i18n-ignore: HTTP error body of the comments route (server messages stay German, like src/server)\n }\n catch (error) {\n if (error instanceof CommentStoreError) return { status: error.status, body: { error: error.message } }\n throw error\n }\n}\n"],"mappings":"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"}
|
package/dist/adapters/vue.js
CHANGED
|
@@ -113,7 +113,7 @@ function useEditorDocument(id, options) {
|
|
|
113
113
|
if (mine !== generation) return;
|
|
114
114
|
modelValue.value = content;
|
|
115
115
|
savedJson.value = fingerprint(content);
|
|
116
|
-
} else throw new Error(
|
|
116
|
+
} else throw new Error(`@ecsplico/editor: loading "${documentId}" failed (${response.status})`);
|
|
117
117
|
ready.value = true;
|
|
118
118
|
} catch (cause) {
|
|
119
119
|
if (mine === generation) error.value = errorOf(cause);
|
|
@@ -135,7 +135,7 @@ function useEditorDocument(id, options) {
|
|
|
135
135
|
body: json
|
|
136
136
|
});
|
|
137
137
|
if (!response.ok) {
|
|
138
|
-
const failure = /* @__PURE__ */ new Error(
|
|
138
|
+
const failure = /* @__PURE__ */ new Error(`@ecsplico/editor: saving "${documentId}" failed (${response.status})`);
|
|
139
139
|
error.value = failure;
|
|
140
140
|
throw failure;
|
|
141
141
|
}
|
package/dist/adapters/vue.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vue.js","names":[],"sources":["../../src/adapters/vue.ts"],"sourcesContent":["import { HocuspocusProvider } from '@hocuspocus/provider'\nimport type { Editor, JSONContent } from '@tiptap/core'\nimport type { AuthorIdentity, CollaborationSession } from '../core'\nimport { computed, type ComputedRef, getCurrentScope, type MaybeRefOrGetter, onScopeDispose, ref, type Ref, shallowRef, type ShallowRef, toValue, watch } from 'vue'\nimport * as Y from 'yjs'\nimport { type CommentsFetch, createRestCommentsModel, type RestCommentsModel } from './comments/client'\nimport { commentsUrl, documentUrl } from './routes'\n\n// The Vue side of an editor document: load it from the host's document endpoint, keep track of unsaved edits, save it,\n// and hand the editor surface what it needs (v-model, collaboration session, comments model). The server side is\n// `installEditorServer` (`./server/nitro`); any server that answers the same two routes works.\n\nexport type { CommentsFetch } from './comments/client'\n\nexport interface EditorDocumentOptions {\n /** How requests are sent: `globalThis.fetch`, or a wrapper that adds the base URL and credentials. Used for the content and the comments. */\n fetch: CommentsFetch\n /**\n * Live collaboration: the collaboration server's websocket URL (`ws://host/collab`; the document id is appended as the\n * provider's `name`) and the optional token its `EditorAdapter.authenticate` receives. With it the Y.Doc is the document:\n * the server loads and stores the content, so the composable neither fetches nor saves it (`modelValue` stays\n * `undefined`, `dirty` stays `false`, `save()` resolves at once).\n */\n collaboration?: { url: string, token?: string | (() => string) | (() => Promise<string>) }\n /** Who writes: labels remote carets and is the author of new comments; `null` or omitted = nobody (no comments). */\n user?: AuthorIdentity | null | (() => AuthorIdentity | null)\n /** Whether the user may comment now (a reader of a read-only document may be refused); default `true`. */\n canComment?: boolean | (() => boolean)\n}\n\n/** The props and events of the editor surface (`SimpleEditor`, `AdvancedEditor`, `EditorSurface`) that `useEditorDocument` fills. */\nexport interface EditorDocumentBinding {\n /** The document (standalone mode); `undefined` in collaboration mode and until it has loaded. */\n modelValue: JSONContent | undefined\n 'onUpdate:modelValue': (content: JSONContent) => void\n /** Hands the live editor to the comments model. */\n onReady: (editor: Editor) => void\n collaboration: CollaborationSession | undefined\n}\n\nexport interface EditorDocument {\n /** The document's TipTap JSON: the surface's `v-model`. `undefined` until loaded, and always in collaboration mode. */\n modelValue: Ref<JSONContent | undefined>\n /** `true` once the surface can be mounted: the content has loaded (a missing document starts empty), or the collaboration session exists. Mount with `:key` = the id, so a changed id remounts it. */\n ready: Ref<boolean>\n /** Edits since the content was loaded or last saved. */\n dirty: ComputedRef<boolean>\n /** Stores the current content (`PUT`); several calls run one after another and an edit made while one is in flight stays dirty. Rejects with the server's refusal. */\n save(): Promise<void>\n /** Fetches the content again and drops unsaved edits. */\n reload(): Promise<void>\n /** The last load or save error, `null` while all is well. */\n error: Ref<Error | null>\n /** The collaboration session for the surface's `collaboration` prop; `undefined` without the `collaboration` option. */\n collaboration: ShallowRef<CollaborationSession | undefined>\n /** The comments model of this document (`commentsFeature({ model: () => comments.value })`), threads behind the host's comments endpoint. A new one per id. */\n comments: Readonly<ShallowRef<RestCommentsModel>>\n /** The live editor, once the surface reported it. */\n editor: ShallowRef<Editor | undefined>\n /** `v-bind` target for the surface: `<AdvancedEditor v-if=\"ready\" :key=\"id\" v-bind=\"bind\" />`. */\n bind: ComputedRef<EditorDocumentBinding>\n /** Closes the collaboration session and the comments model; runs with the effect scope that called `useEditorDocument`. */\n dispose(): void\n}\n\nconst EMPTY_DOCUMENT: JSONContent = { type: 'doc', content: [{ type: 'paragraph' }] }\n\n/**\n * The document as compared for \"unsaved changes\": without block ids. The editor gives every block of loaded content an\n * id the moment it mounts (stored files have none yet); that is not an edit the user made, so it must not mark a\n * freshly opened document dirty. Saving still writes the ids.\n */\nconst withoutBlockIds = (node: JSONContent): JSONContent => {\n const { attrs, content, ...rest } = node\n const kept = attrs && Object.fromEntries(Object.entries(attrs).filter(([name]) => name !== 'id'))\n return {\n ...rest,\n ...(kept && Object.keys(kept).length > 0 && { attrs: kept }),\n ...(content && { content: content.map(withoutBlockIds) }),\n }\n}\nconst fingerprint = (content: JSONContent): string => JSON.stringify(withoutBlockIds(content))\n\n/** Document ids are percent-encoded into one path segment; the collab provider's `name` is the plain id. */\nfunction sessionOf(id: string, { url, token }: NonNullable<EditorDocumentOptions['collaboration']>, user: AuthorIdentity | null): { session: CollaborationSession, close: () => void } {\n const ydoc = new Y.Doc()\n const provider = new HocuspocusProvider({ url: `${url.replace(/\\/+$/, '')}/${encodeURIComponent(id)}`, name: id, document: ydoc, ...(token !== undefined && { token }) })\n return {\n session: { ydoc, provider, ...(user && { user }) },\n close() {\n provider.destroy()\n ydoc.destroy()\n },\n }\n}\n\nconst errorOf = (cause: unknown) => cause instanceof Error ? cause : new Error(String(cause))\n\n/**\n * Loads document `id` from `GET /api/editor/documents/<id>` (a `404` starts an empty document that the first `save()`\n * creates), tracks edits, saves with `PUT`, and prepares the collaboration session and comments model for the editor\n * surface. `id` may be reactive: a change loads the other document and replaces the session and the comments model.\n * Call it in a component's `setup`; the collaboration session is created synchronously, so the surface mounts in the same tick.\n */\nexport function useEditorDocument(id: MaybeRefOrGetter<string>, options: EditorDocumentOptions): EditorDocument {\n const userOf = () => toValue(options.user) ?? null\n const modelValue = ref<JSONContent>()\n const ready = ref(false)\n const error = ref<Error | null>(null)\n const collaboration = shallowRef<CollaborationSession>()\n const editor = shallowRef<Editor>()\n /** Fingerprint (`fingerprint`) of the JSON the server holds (the empty document while it does not exist), `null` before the first load. */\n const savedJson = ref<string | null>(null)\n /** The document does not exist on the server yet: the first save creates it even if nothing was typed. */\n let missing = false\n let closeSession: (() => void) | undefined\n let generation = 0\n let tail: Promise<unknown> = Promise.resolve()\n\n const commentsFor = (documentId: string) => createRestCommentsModel({\n fetch: options.fetch,\n documentId,\n endpoint: commentsUrl(documentId),\n user: userOf,\n canComment: () => toValue(options.canComment) ?? true,\n getEditor: () => editor.value,\n onError: (cause) => { error.value = cause },\n })\n const comments = shallowRef(commentsFor(toValue(id)))\n let commentsId = toValue(id)\n\n async function load(documentId: string): Promise<void> {\n const mine = ++generation\n ready.value = false\n error.value = null\n modelValue.value = undefined\n savedJson.value = null\n missing = false\n editor.value = undefined\n closeSession?.()\n closeSession = undefined\n collaboration.value = undefined\n if (commentsId !== documentId) {\n comments.value.dispose()\n comments.value = commentsFor(documentId)\n commentsId = documentId\n }\n\n if (options.collaboration) {\n const { session, close } = sessionOf(documentId, options.collaboration, userOf())\n closeSession = close\n collaboration.value = session\n ready.value = true\n return\n }\n try {\n const response = await options.fetch(documentUrl(documentId))\n if (mine !== generation) return\n if (response.status === 404) {\n modelValue.value = structuredClone(EMPTY_DOCUMENT)\n savedJson.value = fingerprint(EMPTY_DOCUMENT)\n missing = true\n }\n else if (response.ok) {\n const content = await response.json() as JSONContent\n if (mine !== generation) return\n modelValue.value = content\n savedJson.value = fingerprint(content)\n }\n else {\n throw new Error(`Loading \"${documentId}\" failed (${response.status}).`)\n }\n ready.value = true\n }\n catch (cause) {\n if (mine === generation) error.value = errorOf(cause)\n }\n }\n\n const dirty = computed(() => !options.collaboration && modelValue.value !== undefined && savedJson.value !== null && fingerprint(modelValue.value) !== savedJson.value)\n\n function save(): Promise<void> {\n if (options.collaboration) return Promise.resolve()\n const run = async () => {\n const documentId = toValue(id)\n const content = modelValue.value\n if (content === undefined || !ready.value) return\n const saved = fingerprint(content)\n if (saved === savedJson.value && !missing) return\n const json = JSON.stringify(content)\n const response = await options.fetch(documentUrl(documentId), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: json })\n if (!response.ok) {\n const failure = new Error(`Saving \"${documentId}\" failed (${response.status}).`)\n error.value = failure\n throw failure\n }\n error.value = null\n // Only if the document did not change to another id while the request was in flight.\n if (documentId === toValue(id)) {\n savedJson.value = saved\n missing = false\n }\n }\n const result = tail.then(run, run)\n tail = result.catch(() => {})\n return result\n }\n\n function dispose() {\n generation++\n closeSession?.()\n closeSession = undefined\n collaboration.value = undefined\n comments.value.dispose()\n }\n\n watch(() => toValue(id), documentId => void load(documentId), { immediate: true })\n if (getCurrentScope()) onScopeDispose(dispose)\n\n const bind = computed<EditorDocumentBinding>(() => ({\n modelValue: modelValue.value,\n 'onUpdate:modelValue': (content) => { modelValue.value = content },\n onReady: (instance) => { editor.value = instance },\n collaboration: collaboration.value,\n }))\n\n return { modelValue, ready, dirty, save, reload: () => load(toValue(id)), error, collaboration, comments, editor, bind, dispose }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"vue.js","names":[],"sources":["../../src/adapters/vue.ts"],"sourcesContent":["import { HocuspocusProvider } from '@hocuspocus/provider'\nimport type { Editor, JSONContent } from '@tiptap/core'\nimport type { AuthorIdentity, CollaborationSession } from '../core'\nimport { computed, type ComputedRef, getCurrentScope, type MaybeRefOrGetter, onScopeDispose, ref, type Ref, shallowRef, type ShallowRef, toValue, watch } from 'vue'\nimport * as Y from 'yjs'\nimport { type CommentsFetch, createRestCommentsModel, type RestCommentsModel } from './comments/client'\nimport { commentsUrl, documentUrl } from './routes'\n\n// The Vue side of an editor document: load it from the host's document endpoint, keep track of unsaved edits, save it,\n// and hand the editor surface what it needs (v-model, collaboration session, comments model). The server side is\n// `installEditorServer` (`./server/nitro`); any server that answers the same two routes works.\n\nexport type { CommentsFetch } from './comments/client'\n\nexport interface EditorDocumentOptions {\n /** How requests are sent: `globalThis.fetch`, or a wrapper that adds the base URL and credentials. Used for the content and the comments. */\n fetch: CommentsFetch\n /**\n * Live collaboration: the collaboration server's websocket URL (`ws://host/collab`; the document id is appended as the\n * provider's `name`) and the optional token its `EditorAdapter.authenticate` receives. With it the Y.Doc is the document:\n * the server loads and stores the content, so the composable neither fetches nor saves it (`modelValue` stays\n * `undefined`, `dirty` stays `false`, `save()` resolves at once).\n */\n collaboration?: { url: string, token?: string | (() => string) | (() => Promise<string>) }\n /** Who writes: labels remote carets and is the author of new comments; `null` or omitted = nobody (no comments). */\n user?: AuthorIdentity | null | (() => AuthorIdentity | null)\n /** Whether the user may comment now (a reader of a read-only document may be refused); default `true`. */\n canComment?: boolean | (() => boolean)\n}\n\n/** The props and events of the editor surface (`SimpleEditor`, `AdvancedEditor`, `EditorSurface`) that `useEditorDocument` fills. */\nexport interface EditorDocumentBinding {\n /** The document (standalone mode); `undefined` in collaboration mode and until it has loaded. */\n modelValue: JSONContent | undefined\n 'onUpdate:modelValue': (content: JSONContent) => void\n /** Hands the live editor to the comments model. */\n onReady: (editor: Editor) => void\n collaboration: CollaborationSession | undefined\n}\n\nexport interface EditorDocument {\n /** The document's TipTap JSON: the surface's `v-model`. `undefined` until loaded, and always in collaboration mode. */\n modelValue: Ref<JSONContent | undefined>\n /** `true` once the surface can be mounted: the content has loaded (a missing document starts empty), or the collaboration session exists. Mount with `:key` = the id, so a changed id remounts it. */\n ready: Ref<boolean>\n /** Edits since the content was loaded or last saved. */\n dirty: ComputedRef<boolean>\n /** Stores the current content (`PUT`); several calls run one after another and an edit made while one is in flight stays dirty. Rejects with the server's refusal. */\n save(): Promise<void>\n /** Fetches the content again and drops unsaved edits. */\n reload(): Promise<void>\n /** The last load or save error, `null` while all is well. */\n error: Ref<Error | null>\n /** The collaboration session for the surface's `collaboration` prop; `undefined` without the `collaboration` option. */\n collaboration: ShallowRef<CollaborationSession | undefined>\n /** The comments model of this document (`commentsFeature({ model: () => comments.value })`), threads behind the host's comments endpoint. A new one per id. */\n comments: Readonly<ShallowRef<RestCommentsModel>>\n /** The live editor, once the surface reported it. */\n editor: ShallowRef<Editor | undefined>\n /** `v-bind` target for the surface: `<AdvancedEditor v-if=\"ready\" :key=\"id\" v-bind=\"bind\" />`. */\n bind: ComputedRef<EditorDocumentBinding>\n /** Closes the collaboration session and the comments model; runs with the effect scope that called `useEditorDocument`. */\n dispose(): void\n}\n\nconst EMPTY_DOCUMENT: JSONContent = { type: 'doc', content: [{ type: 'paragraph' }] }\n\n/**\n * The document as compared for \"unsaved changes\": without block ids. The editor gives every block of loaded content an\n * id the moment it mounts (stored files have none yet); that is not an edit the user made, so it must not mark a\n * freshly opened document dirty. Saving still writes the ids.\n */\nconst withoutBlockIds = (node: JSONContent): JSONContent => {\n const { attrs, content, ...rest } = node\n const kept = attrs && Object.fromEntries(Object.entries(attrs).filter(([name]) => name !== 'id'))\n return {\n ...rest,\n ...(kept && Object.keys(kept).length > 0 && { attrs: kept }),\n ...(content && { content: content.map(withoutBlockIds) }),\n }\n}\nconst fingerprint = (content: JSONContent): string => JSON.stringify(withoutBlockIds(content))\n\n/** Document ids are percent-encoded into one path segment; the collab provider's `name` is the plain id. */\nfunction sessionOf(id: string, { url, token }: NonNullable<EditorDocumentOptions['collaboration']>, user: AuthorIdentity | null): { session: CollaborationSession, close: () => void } {\n const ydoc = new Y.Doc()\n const provider = new HocuspocusProvider({ url: `${url.replace(/\\/+$/, '')}/${encodeURIComponent(id)}`, name: id, document: ydoc, ...(token !== undefined && { token }) })\n return {\n session: { ydoc, provider, ...(user && { user }) },\n close() {\n provider.destroy()\n ydoc.destroy()\n },\n }\n}\n\nconst errorOf = (cause: unknown) => cause instanceof Error ? cause : new Error(String(cause))\n\n/**\n * Loads document `id` from `GET /api/editor/documents/<id>` (a `404` starts an empty document that the first `save()`\n * creates), tracks edits, saves with `PUT`, and prepares the collaboration session and comments model for the editor\n * surface. `id` may be reactive: a change loads the other document and replaces the session and the comments model.\n * Call it in a component's `setup`; the collaboration session is created synchronously, so the surface mounts in the same tick.\n */\nexport function useEditorDocument(id: MaybeRefOrGetter<string>, options: EditorDocumentOptions): EditorDocument {\n const userOf = () => toValue(options.user) ?? null\n const modelValue = ref<JSONContent>()\n const ready = ref(false)\n const error = ref<Error | null>(null)\n const collaboration = shallowRef<CollaborationSession>()\n const editor = shallowRef<Editor>()\n /** Fingerprint (`fingerprint`) of the JSON the server holds (the empty document while it does not exist), `null` before the first load. */\n const savedJson = ref<string | null>(null)\n /** The document does not exist on the server yet: the first save creates it even if nothing was typed. */\n let missing = false\n let closeSession: (() => void) | undefined\n let generation = 0\n let tail: Promise<unknown> = Promise.resolve()\n\n const commentsFor = (documentId: string) => createRestCommentsModel({\n fetch: options.fetch,\n documentId,\n endpoint: commentsUrl(documentId),\n user: userOf,\n canComment: () => toValue(options.canComment) ?? true,\n getEditor: () => editor.value,\n onError: (cause) => { error.value = cause },\n })\n const comments = shallowRef(commentsFor(toValue(id)))\n let commentsId = toValue(id)\n\n async function load(documentId: string): Promise<void> {\n const mine = ++generation\n ready.value = false\n error.value = null\n modelValue.value = undefined\n savedJson.value = null\n missing = false\n editor.value = undefined\n closeSession?.()\n closeSession = undefined\n collaboration.value = undefined\n if (commentsId !== documentId) {\n comments.value.dispose()\n comments.value = commentsFor(documentId)\n commentsId = documentId\n }\n\n if (options.collaboration) {\n const { session, close } = sessionOf(documentId, options.collaboration, userOf())\n closeSession = close\n collaboration.value = session\n ready.value = true\n return\n }\n try {\n const response = await options.fetch(documentUrl(documentId))\n if (mine !== generation) return\n if (response.status === 404) {\n modelValue.value = structuredClone(EMPTY_DOCUMENT)\n savedJson.value = fingerprint(EMPTY_DOCUMENT)\n missing = true\n }\n else if (response.ok) {\n const content = await response.json() as JSONContent\n if (mine !== generation) return\n modelValue.value = content\n savedJson.value = fingerprint(content)\n }\n else {\n throw new Error(`@ecsplico/editor: loading \"${documentId}\" failed (${response.status})`)\n }\n ready.value = true\n }\n catch (cause) {\n if (mine === generation) error.value = errorOf(cause)\n }\n }\n\n const dirty = computed(() => !options.collaboration && modelValue.value !== undefined && savedJson.value !== null && fingerprint(modelValue.value) !== savedJson.value)\n\n function save(): Promise<void> {\n if (options.collaboration) return Promise.resolve()\n const run = async () => {\n const documentId = toValue(id)\n const content = modelValue.value\n if (content === undefined || !ready.value) return\n const saved = fingerprint(content)\n if (saved === savedJson.value && !missing) return\n const json = JSON.stringify(content)\n const response = await options.fetch(documentUrl(documentId), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: json })\n if (!response.ok) {\n const failure = new Error(`@ecsplico/editor: saving \"${documentId}\" failed (${response.status})`)\n error.value = failure\n throw failure\n }\n error.value = null\n // Only if the document did not change to another id while the request was in flight.\n if (documentId === toValue(id)) {\n savedJson.value = saved\n missing = false\n }\n }\n const result = tail.then(run, run)\n tail = result.catch(() => {})\n return result\n }\n\n function dispose() {\n generation++\n closeSession?.()\n closeSession = undefined\n collaboration.value = undefined\n comments.value.dispose()\n }\n\n watch(() => toValue(id), documentId => void load(documentId), { immediate: true })\n if (getCurrentScope()) onScopeDispose(dispose)\n\n const bind = computed<EditorDocumentBinding>(() => ({\n modelValue: modelValue.value,\n 'onUpdate:modelValue': (content) => { modelValue.value = content },\n onReady: (instance) => { editor.value = instance },\n collaboration: collaboration.value,\n }))\n\n return { modelValue, ready, dirty, save, reload: () => load(toValue(id)), error, collaboration, comments, editor, bind, dispose }\n}\n"],"mappings":";;;;;;AAiEA,MAAM,iBAA8B;CAAE,MAAM;CAAO,SAAS,CAAC,EAAE,MAAM,YAAY,CAAC;AAAE;;;;;;AAOpF,MAAM,mBAAmB,SAAmC;CAC1D,MAAM,EAAE,OAAO,SAAS,GAAG,SAAS;CACpC,MAAM,OAAO,SAAS,OAAO,YAAY,OAAO,QAAQ,KAAK,CAAC,CAAC,QAAQ,CAAC,UAAU,SAAS,IAAI,CAAC;CAChG,OAAO;EACL,GAAG;EACH,GAAI,QAAQ,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,KAAK,EAAE,OAAO,KAAK;EAC1D,GAAI,WAAW,EAAE,SAAS,QAAQ,IAAI,eAAe,EAAE;CACzD;AACF;AACA,MAAM,eAAe,YAAiC,KAAK,UAAU,gBAAgB,OAAO,CAAC;;AAG7F,SAAS,UAAU,IAAY,EAAE,KAAK,SAA8D,MAAmF;CACrL,MAAM,OAAO,IAAI,EAAE,IAAI;CACvB,MAAM,WAAW,IAAI,mBAAmB;EAAE,KAAK,GAAG,IAAI,QAAQ,QAAQ,EAAE,EAAE,GAAG,mBAAmB,EAAE;EAAK,MAAM;EAAI,UAAU;EAAM,GAAI,UAAU,KAAA,KAAa,EAAE,MAAM;CAAG,CAAC;CACxK,OAAO;EACL,SAAS;GAAE;GAAM;GAAU,GAAI,QAAQ,EAAE,KAAK;EAAG;EACjD,QAAQ;GACN,SAAS,QAAQ;GACjB,KAAK,QAAQ;EACf;CACF;AACF;AAEA,MAAM,WAAW,UAAmB,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;;;;;;;AAQ5F,SAAgB,kBAAkB,IAA8B,SAAgD;CAC9G,MAAM,eAAe,QAAQ,QAAQ,IAAI,KAAK;CAC9C,MAAM,aAAa,IAAiB;CACpC,MAAM,QAAQ,IAAI,KAAK;CACvB,MAAM,QAAQ,IAAkB,IAAI;CACpC,MAAM,gBAAgB,WAAiC;CACvD,MAAM,SAAS,WAAmB;;CAElC,MAAM,YAAY,IAAmB,IAAI;;CAEzC,IAAI,UAAU;CACd,IAAI;CACJ,IAAI,aAAa;CACjB,IAAI,OAAyB,QAAQ,QAAQ;CAE7C,MAAM,eAAe,eAAuB,wBAAwB;EAClE,OAAO,QAAQ;EACf;EACA,UAAU,YAAY,UAAU;EAChC,MAAM;EACN,kBAAkB,QAAQ,QAAQ,UAAU,KAAK;EACjD,iBAAiB,OAAO;EACxB,UAAU,UAAU;GAAE,MAAM,QAAQ;EAAM;CAC5C,CAAC;CACD,MAAM,WAAW,WAAW,YAAY,QAAQ,EAAE,CAAC,CAAC;CACpD,IAAI,aAAa,QAAQ,EAAE;CAE3B,eAAe,KAAK,YAAmC;EACrD,MAAM,OAAO,EAAE;EACf,MAAM,QAAQ;EACd,MAAM,QAAQ;EACd,WAAW,QAAQ,KAAA;EACnB,UAAU,QAAQ;EAClB,UAAU;EACV,OAAO,QAAQ,KAAA;EACf,eAAe;EACf,eAAe,KAAA;EACf,cAAc,QAAQ,KAAA;EACtB,IAAI,eAAe,YAAY;GAC7B,SAAS,MAAM,QAAQ;GACvB,SAAS,QAAQ,YAAY,UAAU;GACvC,aAAa;EACf;EAEA,IAAI,QAAQ,eAAe;GACzB,MAAM,EAAE,SAAS,UAAU,UAAU,YAAY,QAAQ,eAAe,OAAO,CAAC;GAChF,eAAe;GACf,cAAc,QAAQ;GACtB,MAAM,QAAQ;GACd;EACF;EACA,IAAI;GACF,MAAM,WAAW,MAAM,QAAQ,MAAM,YAAY,UAAU,CAAC;GAC5D,IAAI,SAAS,YAAY;GACzB,IAAI,SAAS,WAAW,KAAK;IAC3B,WAAW,QAAQ,gBAAgB,cAAc;IACjD,UAAU,QAAQ,YAAY,cAAc;IAC5C,UAAU;GACZ,OACK,IAAI,SAAS,IAAI;IACpB,MAAM,UAAU,MAAM,SAAS,KAAK;IACpC,IAAI,SAAS,YAAY;IACzB,WAAW,QAAQ;IACnB,UAAU,QAAQ,YAAY,OAAO;GACvC,OAEE,MAAM,IAAI,MAAM,8BAA8B,WAAW,YAAY,SAAS,OAAO,EAAE;GAEzF,MAAM,QAAQ;EAChB,SACO,OAAO;GACZ,IAAI,SAAS,YAAY,MAAM,QAAQ,QAAQ,KAAK;EACtD;CACF;CAEA,MAAM,QAAQ,eAAe,CAAC,QAAQ,iBAAiB,WAAW,UAAU,KAAA,KAAa,UAAU,UAAU,QAAQ,YAAY,WAAW,KAAK,MAAM,UAAU,KAAK;CAEtK,SAAS,OAAsB;EAC7B,IAAI,QAAQ,eAAe,OAAO,QAAQ,QAAQ;EAClD,MAAM,MAAM,YAAY;GACtB,MAAM,aAAa,QAAQ,EAAE;GAC7B,MAAM,UAAU,WAAW;GAC3B,IAAI,YAAY,KAAA,KAAa,CAAC,MAAM,OAAO;GAC3C,MAAM,QAAQ,YAAY,OAAO;GACjC,IAAI,UAAU,UAAU,SAAS,CAAC,SAAS;GAC3C,MAAM,OAAO,KAAK,UAAU,OAAO;GACnC,MAAM,WAAW,MAAM,QAAQ,MAAM,YAAY,UAAU,GAAG;IAAE,QAAQ;IAAO,SAAS,EAAE,gBAAgB,mBAAmB;IAAG,MAAM;GAAK,CAAC;GAC5I,IAAI,CAAC,SAAS,IAAI;IAChB,MAAM,0BAAU,IAAI,MAAM,6BAA6B,WAAW,YAAY,SAAS,OAAO,EAAE;IAChG,MAAM,QAAQ;IACd,MAAM;GACR;GACA,MAAM,QAAQ;GAEd,IAAI,eAAe,QAAQ,EAAE,GAAG;IAC9B,UAAU,QAAQ;IAClB,UAAU;GACZ;EACF;EACA,MAAM,SAAS,KAAK,KAAK,KAAK,GAAG;EACjC,OAAO,OAAO,YAAY,CAAC,CAAC;EAC5B,OAAO;CACT;CAEA,SAAS,UAAU;EACjB;EACA,eAAe;EACf,eAAe,KAAA;EACf,cAAc,QAAQ,KAAA;EACtB,SAAS,MAAM,QAAQ;CACzB;CAEA,YAAY,QAAQ,EAAE,IAAG,eAAc,KAAK,KAAK,UAAU,GAAG,EAAE,WAAW,KAAK,CAAC;CACjF,IAAI,gBAAgB,GAAG,eAAe,OAAO;CAS7C,OAAO;EAAE;EAAY;EAAO;EAAO;EAAM,cAAc,KAAK,QAAQ,EAAE,CAAC;EAAG;EAAO;EAAe;EAAU;EAAQ,MAPrG,gBAAuC;GAClD,YAAY,WAAW;GACvB,wBAAwB,YAAY;IAAE,WAAW,QAAQ;GAAQ;GACjE,UAAU,aAAa;IAAE,OAAO,QAAQ;GAAS;GACjD,eAAe,cAAc;EAC/B,EAEqH;EAAG;CAAQ;AAClI"}
|
|
@@ -13,5 +13,26 @@ export interface BlockActionEntry {
|
|
|
13
13
|
export declare function effectiveTarget(editor: Editor, target: BlockTarget): BlockTarget;
|
|
14
14
|
/** The actions `target` offers; a `menu` action only when its menu has items. */
|
|
15
15
|
export declare function blockActionsFor(editor: Editor, target: BlockTarget, actions: readonly BlockAction[]): BlockActionEntry[];
|
|
16
|
+
/**
|
|
17
|
+
* Attribute a block's own chrome sets on the elements it shows in its top-right corner (status badges, kind or menu
|
|
18
|
+
* buttons, ...). The hover bar docks immediately left of them, vertically centred on them, so both read as one row
|
|
19
|
+
* instead of the bar covering the block's icons. A block without such elements gets the bar at the editor's right edge.
|
|
20
|
+
*/
|
|
21
|
+
export declare const BLOCK_CORNER_ATTR = "data-block-corner";
|
|
22
|
+
/** Where a block's corner elements sit, in viewport coordinates. */
|
|
23
|
+
export interface BlockCorner {
|
|
24
|
+
/** Left edge of the leftmost corner element. */
|
|
25
|
+
left: number;
|
|
26
|
+
/** Vertical centre of the first corner element. */
|
|
27
|
+
centerY: number;
|
|
28
|
+
/** The marked elements, for a host that wants to watch them for size changes. */
|
|
29
|
+
elements: HTMLElement[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The corner elements `dom` (a block's node view root) marks with `BLOCK_CORNER_ATTR`, or null when none is shown.
|
|
33
|
+
* Elements inside a nested block's own node view belong to that block and do not count; neither do hidden ones (no
|
|
34
|
+
* layout box).
|
|
35
|
+
*/
|
|
36
|
+
export declare function blockCornerOf(dom: HTMLElement): BlockCorner | null;
|
|
16
37
|
//#endregion
|
|
17
38
|
//# sourceMappingURL=block-actions.d.ts.map
|
|
@@ -31,7 +31,31 @@ function blockActionsFor(editor, target, actions) {
|
|
|
31
31
|
}] : [];
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Attribute a block's own chrome sets on the elements it shows in its top-right corner (status badges, kind or menu
|
|
36
|
+
* buttons, ...). The hover bar docks immediately left of them, vertically centred on them, so both read as one row
|
|
37
|
+
* instead of the bar covering the block's icons. A block without such elements gets the bar at the editor's right edge.
|
|
38
|
+
*/
|
|
39
|
+
const BLOCK_CORNER_ATTR = "data-block-corner";
|
|
40
|
+
/**
|
|
41
|
+
* The corner elements `dom` (a block's node view root) marks with `BLOCK_CORNER_ATTR`, or null when none is shown.
|
|
42
|
+
* Elements inside a nested block's own node view belong to that block and do not count; neither do hidden ones (no
|
|
43
|
+
* layout box).
|
|
44
|
+
*/
|
|
45
|
+
function blockCornerOf(dom) {
|
|
46
|
+
const elements = [...dom.querySelectorAll(`[${BLOCK_CORNER_ATTR}]`)].filter((element) => {
|
|
47
|
+
const owner = element.closest("[data-node-view-wrapper]");
|
|
48
|
+
return !(owner && owner !== dom && dom.contains(owner));
|
|
49
|
+
});
|
|
50
|
+
const rects = elements.map((element) => element.getBoundingClientRect()).filter((rect) => rect.width > 0 && rect.height > 0);
|
|
51
|
+
if (!rects.length) return null;
|
|
52
|
+
return {
|
|
53
|
+
left: Math.min(...rects.map((rect) => rect.left)),
|
|
54
|
+
centerY: rects[0].top + rects[0].height / 2,
|
|
55
|
+
elements
|
|
56
|
+
};
|
|
57
|
+
}
|
|
34
58
|
//#endregion
|
|
35
|
-
export { blockActionsFor, effectiveTarget };
|
|
59
|
+
export { BLOCK_CORNER_ATTR, blockActionsFor, blockCornerOf, effectiveTarget };
|
|
36
60
|
|
|
37
61
|
//# sourceMappingURL=block-actions.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"block-actions.js","names":[],"sources":["../../../src/features/block-ui/block-actions.ts"],"sourcesContent":["import type { Editor } from '@tiptap/core'\nimport { abilitiesAt, type BlockAction, type BlockActionContext, type BlockTarget, type MenuItem } from '../../core'\nimport { isChromeNode } from '../../core/schema'\n\n/** An action `target` offers, with the context it runs with and, for a `menu` action, its items. */\nexport interface BlockActionEntry {\n action: BlockAction\n ctx: BlockActionContext\n menu?: MenuItem[]\n}\n\n/** Hovering a composite's title/description/tags row addresses the composite itself. */\nexport function effectiveTarget(editor: Editor, target: BlockTarget): BlockTarget {\n if (!isChromeNode(target.node)) return target\n const $pos = editor.state.doc.resolve(target.pos)\n return { node: $pos.parent, pos: $pos.before() }\n}\n\n/** The actions `target` offers; a `menu` action only when its menu has items. */\nexport function blockActionsFor(editor: Editor, target: BlockTarget, actions: readonly BlockAction[]): BlockActionEntry[] {\n const ctx: BlockActionContext = { ...target, editor, abilities: abilitiesAt(editor, target.pos, target.node) }\n return actions.flatMap((action): BlockActionEntry[] => {\n if (!ctx.abilities.has(action.ability) || action.isVisible?.(ctx) === false) return []\n if (!action.menu) return [{ action, ctx }]\n const menu = action.menu(ctx)\n return menu.length ? [{ action, ctx, menu }] : []\n })\n}\n"],"mappings":";;;;AAYA,SAAgB,gBAAgB,QAAgB,QAAkC;CAChF,IAAI,CAAC,aAAa,OAAO,IAAI,GAAG,OAAO;CACvC,MAAM,OAAO,OAAO,MAAM,IAAI,QAAQ,OAAO,GAAG;CAChD,OAAO;EAAE,MAAM,KAAK;EAAQ,KAAK,KAAK,OAAO;CAAE;AACjD;;AAGA,SAAgB,gBAAgB,QAAgB,QAAqB,SAAqD;CACxH,MAAM,MAA0B;EAAE,GAAG;EAAQ;EAAQ,WAAW,YAAY,QAAQ,OAAO,KAAK,OAAO,IAAI;CAAE;CAC7G,OAAO,QAAQ,SAAS,WAA+B;EACrD,IAAI,CAAC,IAAI,UAAU,IAAI,OAAO,OAAO,KAAK,OAAO,YAAY,GAAG,MAAM,OAAO,OAAO,CAAC;EACrF,IAAI,CAAC,OAAO,MAAM,OAAO,CAAC;GAAE;GAAQ;EAAI,CAAC;EACzC,MAAM,OAAO,OAAO,KAAK,GAAG;EAC5B,OAAO,KAAK,SAAS,CAAC;GAAE;GAAQ;GAAK;EAAK,CAAC,IAAI,CAAC;CAClD,CAAC;AACH"}
|
|
1
|
+
{"version":3,"file":"block-actions.js","names":[],"sources":["../../../src/features/block-ui/block-actions.ts"],"sourcesContent":["import type { Editor } from '@tiptap/core'\nimport { abilitiesAt, type BlockAction, type BlockActionContext, type BlockTarget, type MenuItem } from '../../core'\nimport { isChromeNode } from '../../core/schema'\n\n/** An action `target` offers, with the context it runs with and, for a `menu` action, its items. */\nexport interface BlockActionEntry {\n action: BlockAction\n ctx: BlockActionContext\n menu?: MenuItem[]\n}\n\n/** Hovering a composite's title/description/tags row addresses the composite itself. */\nexport function effectiveTarget(editor: Editor, target: BlockTarget): BlockTarget {\n if (!isChromeNode(target.node)) return target\n const $pos = editor.state.doc.resolve(target.pos)\n return { node: $pos.parent, pos: $pos.before() }\n}\n\n/** The actions `target` offers; a `menu` action only when its menu has items. */\nexport function blockActionsFor(editor: Editor, target: BlockTarget, actions: readonly BlockAction[]): BlockActionEntry[] {\n const ctx: BlockActionContext = { ...target, editor, abilities: abilitiesAt(editor, target.pos, target.node) }\n return actions.flatMap((action): BlockActionEntry[] => {\n if (!ctx.abilities.has(action.ability) || action.isVisible?.(ctx) === false) return []\n if (!action.menu) return [{ action, ctx }]\n const menu = action.menu(ctx)\n return menu.length ? [{ action, ctx, menu }] : []\n })\n}\n\n/**\n * Attribute a block's own chrome sets on the elements it shows in its top-right corner (status badges, kind or menu\n * buttons, ...). The hover bar docks immediately left of them, vertically centred on them, so both read as one row\n * instead of the bar covering the block's icons. A block without such elements gets the bar at the editor's right edge.\n */\nexport const BLOCK_CORNER_ATTR = 'data-block-corner'\n\n/** Where a block's corner elements sit, in viewport coordinates. */\nexport interface BlockCorner {\n /** Left edge of the leftmost corner element. */\n left: number\n /** Vertical centre of the first corner element. */\n centerY: number\n /** The marked elements, for a host that wants to watch them for size changes. */\n elements: HTMLElement[]\n}\n\n/**\n * The corner elements `dom` (a block's node view root) marks with `BLOCK_CORNER_ATTR`, or null when none is shown.\n * Elements inside a nested block's own node view belong to that block and do not count; neither do hidden ones (no\n * layout box).\n */\nexport function blockCornerOf(dom: HTMLElement): BlockCorner | null {\n const elements = [...dom.querySelectorAll<HTMLElement>(`[${BLOCK_CORNER_ATTR}]`)].filter((element) => {\n const owner = element.closest('[data-node-view-wrapper]')\n return !(owner && owner !== dom && dom.contains(owner))\n })\n const rects = elements.map(element => element.getBoundingClientRect()).filter(rect => rect.width > 0 && rect.height > 0)\n if (!rects.length) return null\n return {\n left: Math.min(...rects.map(rect => rect.left)),\n centerY: rects[0]!.top + rects[0]!.height / 2,\n elements,\n }\n}\n"],"mappings":";;;;AAYA,SAAgB,gBAAgB,QAAgB,QAAkC;CAChF,IAAI,CAAC,aAAa,OAAO,IAAI,GAAG,OAAO;CACvC,MAAM,OAAO,OAAO,MAAM,IAAI,QAAQ,OAAO,GAAG;CAChD,OAAO;EAAE,MAAM,KAAK;EAAQ,KAAK,KAAK,OAAO;CAAE;AACjD;;AAGA,SAAgB,gBAAgB,QAAgB,QAAqB,SAAqD;CACxH,MAAM,MAA0B;EAAE,GAAG;EAAQ;EAAQ,WAAW,YAAY,QAAQ,OAAO,KAAK,OAAO,IAAI;CAAE;CAC7G,OAAO,QAAQ,SAAS,WAA+B;EACrD,IAAI,CAAC,IAAI,UAAU,IAAI,OAAO,OAAO,KAAK,OAAO,YAAY,GAAG,MAAM,OAAO,OAAO,CAAC;EACrF,IAAI,CAAC,OAAO,MAAM,OAAO,CAAC;GAAE;GAAQ;EAAI,CAAC;EACzC,MAAM,OAAO,OAAO,KAAK,GAAG;EAC5B,OAAO,KAAK,SAAS,CAAC;GAAE;GAAQ;GAAK;EAAK,CAAC,IAAI,CAAC;CAClD,CAAC;AACH;;;;;;AAOA,MAAa,oBAAoB;;;;;;AAiBjC,SAAgB,cAAc,KAAsC;CAClE,MAAM,WAAW,CAAC,GAAG,IAAI,iBAA8B,IAAI,kBAAkB,EAAE,CAAC,CAAC,CAAC,QAAQ,YAAY;EACpG,MAAM,QAAQ,QAAQ,QAAQ,0BAA0B;EACxD,OAAO,EAAE,SAAS,UAAU,OAAO,IAAI,SAAS,KAAK;CACvD,CAAC;CACD,MAAM,QAAQ,SAAS,KAAI,YAAW,QAAQ,sBAAsB,CAAC,CAAC,CAAC,QAAO,SAAQ,KAAK,QAAQ,KAAK,KAAK,SAAS,CAAC;CACvH,IAAI,CAAC,MAAM,QAAQ,OAAO;CAC1B,OAAO;EACL,MAAM,KAAK,IAAI,GAAG,MAAM,KAAI,SAAQ,KAAK,IAAI,CAAC;EAC9C,SAAS,MAAM,EAAE,CAAE,MAAM,MAAM,EAAE,CAAE,SAAS;EAC5C;CACF;AACF"}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { useUiKit } from "../../../vue/kit/use-ui-kit.js";
|
|
2
|
-
import { blockActionsFor, effectiveTarget } from "../block-actions.js";
|
|
2
|
+
import { blockActionsFor, blockCornerOf, effectiveTarget } from "../block-actions.js";
|
|
3
3
|
import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createVNode, defineComponent, normalizeStyle, onBeforeUnmount, onMounted, openBlock, ref, renderList, unref, useTemplateRef, watch, withCtx, withModifiers } from "vue";
|
|
4
4
|
//#region src/features/block-ui/components/BlockActionsOverlay.vue
|
|
5
5
|
/** Gap between the bar and the editor's right edge. */
|
|
6
6
|
const EDGE_GUTTER = 8;
|
|
7
|
+
/** Gap between the bar and the block's own corner elements it docks next to. */
|
|
8
|
+
const CORNER_GAP = 4;
|
|
7
9
|
/**
|
|
8
10
|
* The hover actions of a block, right-aligned at the editor's text column (the drag handle sits on the block's left and
|
|
9
11
|
* targets the same block). Positioned absolutely inside `host`, a `relative` element around the editor; the composite
|
|
10
12
|
* chrome (title, description, tags) addresses the composite (`effectiveTarget`). The bar is hidden while the editor is
|
|
11
13
|
* read-only. `menu` actions open a dropdown of their items instead of running on click.
|
|
14
|
+
*
|
|
15
|
+
* A block that shows icons of its own in its top-right corner marks them with `data-block-corner` (`BLOCK_CORNER_ATTR`):
|
|
16
|
+
* the bar then docks immediately left of them, centred on their row, instead of covering them. Without such elements
|
|
17
|
+
* it sits at the editor's right edge, aligned to the block's top.
|
|
12
18
|
*/
|
|
13
19
|
const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
14
20
|
__name: "BlockActionsOverlay",
|
|
@@ -43,13 +49,33 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
43
49
|
const items = computed(() => block.value ? blockActionsFor(props.editor, block.value, props.actions) : []);
|
|
44
50
|
const top = ref(0);
|
|
45
51
|
const right = ref(0);
|
|
52
|
+
/** Whether the bar is docked next to the block's corner elements: `top` is then their vertical centre. */
|
|
53
|
+
const docked = ref(false);
|
|
46
54
|
const measured = ref(false);
|
|
55
|
+
let watched = [];
|
|
56
|
+
function watchCorner(elements) {
|
|
57
|
+
if (elements.length === watched.length && elements.every((element, index) => element === watched[index])) return;
|
|
58
|
+
for (const element of watched) resizeObserver?.unobserve(element);
|
|
59
|
+
watched = elements;
|
|
60
|
+
for (const element of watched) resizeObserver?.observe(element);
|
|
61
|
+
}
|
|
47
62
|
function measure() {
|
|
48
63
|
const { editor, host } = props;
|
|
49
64
|
const dom = block.value && host && !editor.isDestroyed ? editor.view.nodeDOM(block.value.pos) : null;
|
|
50
65
|
measured.value = dom instanceof HTMLElement;
|
|
51
|
-
if (!(dom instanceof HTMLElement) || !host)
|
|
66
|
+
if (!(dom instanceof HTMLElement) || !host) {
|
|
67
|
+
watchCorner([]);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
52
70
|
const hostRect = host.getBoundingClientRect();
|
|
71
|
+
const corner = blockCornerOf(dom);
|
|
72
|
+
watchCorner(corner?.elements ?? []);
|
|
73
|
+
docked.value = corner !== null;
|
|
74
|
+
if (corner) {
|
|
75
|
+
top.value = corner.centerY - hostRect.top;
|
|
76
|
+
right.value = hostRect.right - corner.left + CORNER_GAP;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
53
79
|
const rect = dom.getBoundingClientRect();
|
|
54
80
|
top.value = rect.top - hostRect.top + 2;
|
|
55
81
|
right.value = hostRect.right - editor.view.dom.getBoundingClientRect().right + EDGE_GUTTER;
|
|
@@ -76,21 +102,24 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
76
102
|
window.addEventListener("scroll", measure, true);
|
|
77
103
|
resizeObserver = new ResizeObserver(measure);
|
|
78
104
|
resizeObserver.observe(props.editor.view.dom);
|
|
105
|
+
for (const element of watched) resizeObserver.observe(element);
|
|
79
106
|
});
|
|
80
107
|
onBeforeUnmount(() => {
|
|
81
108
|
window.removeEventListener("resize", measure);
|
|
82
109
|
window.removeEventListener("scroll", measure, true);
|
|
83
110
|
resizeObserver?.disconnect();
|
|
111
|
+
watched = [];
|
|
84
112
|
});
|
|
85
113
|
return (_ctx, _cache) => {
|
|
86
114
|
return visible.value ? (openBlock(), createElementBlock("div", {
|
|
87
115
|
key: 0,
|
|
88
116
|
ref_key: "root",
|
|
89
117
|
ref: root,
|
|
90
|
-
class: "block-actions absolute z-10 flex gap-0.5 rounded-md
|
|
118
|
+
class: "block-actions absolute z-10 flex gap-0.5 rounded-md bg-[var(--sk-bg)] p-0.5",
|
|
91
119
|
style: normalizeStyle({
|
|
92
120
|
top: `${top.value}px`,
|
|
93
|
-
right: `${right.value}px
|
|
121
|
+
right: `${right.value}px`,
|
|
122
|
+
transform: docked.value ? "translateY(-50%)" : void 0
|
|
94
123
|
}),
|
|
95
124
|
onMouseenter: _cache[2] || (_cache[2] = ($event) => emit("hover", true)),
|
|
96
125
|
onMouseleave: onMouseLeave
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BlockActionsOverlay.js","names":["_openBlock","_createElementBlock","_normalizeStyle","_Fragment","_renderList","_createBlock","_unref","_withCtx","_createVNode","_withModifiers","_createCommentVNode"],"sources":["../../../../src/features/block-ui/components/BlockActionsOverlay.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { useUiKit } from '../../../vue/kit/use-ui-kit'\nimport type { Editor } from '@tiptap/vue-3'\nimport { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'\nimport type { BlockAction, BlockTarget } from '../../../core'\nimport { blockActionsFor, effectiveTarget } from '../block-actions'\n\n/** Gap between the bar and the editor's right edge. */\nconst EDGE_GUTTER = 8\n\n/**\n * The hover actions of a block, right-aligned at the editor's text column (the drag handle sits on the block's left and\n * targets the same block). Positioned absolutely inside `host`, a `relative` element around the editor; the composite\n * chrome (title, description, tags) addresses the composite (`effectiveTarget`). The bar is hidden while the editor is\n * read-only. `menu` actions open a dropdown of their items instead of running on click.\n */\nconst props = defineProps<{\n editor: Editor\n target: BlockTarget | null\n actions: readonly BlockAction[]\n host: HTMLElement | null\n}>()\n\nconst { Button: UButton, DropdownMenu: UDropdownMenu, Tooltip: UTooltip } = useUiKit()\n\nconst emit = defineEmits<{ hover: [hovered: boolean] }>()\n\n/** The target in the current document; null once its position holds something else (deleted, replaced). */\nconst block = computed<BlockTarget | null>(() => {\n const { editor, target } = props\n if (!target || editor.isDestroyed) return null\n const { doc } = editor.state\n if (target.pos < 0 || target.pos >= doc.content.size) return null\n const node = doc.nodeAt(target.pos)\n if (!node || node.type !== target.node.type) return null\n return effectiveTarget(editor, { node, pos: target.pos })\n})\n\n// Reading `state` makes the flag follow `setEditable` (which dispatches a transaction).\nconst editable = computed(() => {\n void props.editor.state\n return props.editor.isEditable\n})\n\nconst items = computed(() => (block.value ? blockActionsFor(props.editor, block.value, props.actions) : []))\n\nconst top = ref(0)\nconst right = ref(0)\nconst measured = ref(false)\n\nfunction measure() {\n const { editor, host } = props\n const dom = block.value && host && !editor.isDestroyed ? editor.view.nodeDOM(block.value.pos) : null\n measured.value = dom instanceof HTMLElement\n if (!(dom instanceof HTMLElement) || !host) return\n const hostRect = host.getBoundingClientRect()\n const rect = dom.getBoundingClientRect()\n top.value = rect.top - hostRect.top + 2\n right.value = hostRect.right - editor.view.dom.getBoundingClientRect().right + EDGE_GUTTER\n}\n\nwatch([block, () => props.editor.state], measure, { flush: 'post' })\n\nconst visible = computed(() => editable.value && items.value.length > 0 && measured.value)\n\n// An open action menu is rendered outside the overlay (portal): the overlay counts as hovered until the menu closes.\nconst root = useTemplateRef<HTMLElement>('root')\nconst menuOpen = ref(false)\nfunction onMenuOpen(open: boolean) {\n menuOpen.value = open\n emit('hover', open || !!root.value?.matches(':hover'))\n}\nfunction onMouseLeave() {\n if (!menuOpen.value) emit('hover', false)\n}\n\n// A vanishing overlay sends no mouseleave: report it, so the host does not keep waiting for one.\nwatch(visible, (value) => {\n if (value) return\n menuOpen.value = false\n emit('hover', false)\n})\n\n// The bar follows the editor's right edge when the column resizes without the window doing so.\nlet resizeObserver: ResizeObserver | undefined\nonMounted(() => {\n window.addEventListener('resize', measure)\n window.addEventListener('scroll', measure, true)\n resizeObserver = new ResizeObserver(measure)\n resizeObserver.observe(props.editor.view.dom)\n})\n\nonBeforeUnmount(() => {\n window.removeEventListener('resize', measure)\n window.removeEventListener('scroll', measure, true)\n resizeObserver?.disconnect()\n})\n</script>\n\n<template>\n <div\n v-if=\"visible\"\n ref=\"root\"\n class=\"block-actions absolute z-10 flex gap-0.5 rounded-md border border-[var(--sk-border)] bg-[var(--sk-bg)] p-0.5 shadow-sm\"\n :style=\"{ top: `${top}px`, right: `${right}px` }\"\n @mouseenter=\"emit('hover', true)\"\n @mouseleave=\"onMouseLeave\"\n >\n <template v-for=\"entry in items\" :key=\"entry.action.id\">\n <UDropdownMenu v-if=\"entry.menu\" :items=\"entry.menu\" :content=\"{ align: 'end' }\" @update:open=\"onMenuOpen\">\n <UTooltip :text=\"entry.action.label\">\n <UButton\n size=\"xs\"\n color=\"neutral\"\n variant=\"ghost\"\n :icon=\"entry.action.icon\"\n :aria-label=\"entry.action.label\"\n @mousedown.prevent\n />\n </UTooltip>\n </UDropdownMenu>\n <UTooltip v-else :text=\"entry.action.label\">\n <UButton\n size=\"xs\"\n color=\"neutral\"\n variant=\"ghost\"\n :icon=\"entry.action.icon\"\n :aria-label=\"entry.action.label\"\n @mousedown.prevent\n @click=\"entry.action.run?.(entry.ctx)\"\n />\n </UTooltip>\n </template>\n </div>\n</template>\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"BlockActionsOverlay.js","names":["_openBlock","_createElementBlock","_normalizeStyle","_Fragment","_renderList","_createBlock","_unref","_withCtx","_createVNode","_withModifiers","_createCommentVNode"],"sources":["../../../../src/features/block-ui/components/BlockActionsOverlay.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { useUiKit } from '../../../vue/kit/use-ui-kit'\nimport type { Editor } from '@tiptap/vue-3'\nimport { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'\nimport type { BlockAction, BlockTarget } from '../../../core'\nimport { blockActionsFor, blockCornerOf, effectiveTarget } from '../block-actions'\n\n/** Gap between the bar and the editor's right edge. */\nconst EDGE_GUTTER = 8\n/** Gap between the bar and the block's own corner elements it docks next to. */\nconst CORNER_GAP = 4\n\n/**\n * The hover actions of a block, right-aligned at the editor's text column (the drag handle sits on the block's left and\n * targets the same block). Positioned absolutely inside `host`, a `relative` element around the editor; the composite\n * chrome (title, description, tags) addresses the composite (`effectiveTarget`). The bar is hidden while the editor is\n * read-only. `menu` actions open a dropdown of their items instead of running on click.\n *\n * A block that shows icons of its own in its top-right corner marks them with `data-block-corner` (`BLOCK_CORNER_ATTR`):\n * the bar then docks immediately left of them, centred on their row, instead of covering them. Without such elements\n * it sits at the editor's right edge, aligned to the block's top.\n */\nconst props = defineProps<{\n editor: Editor\n target: BlockTarget | null\n actions: readonly BlockAction[]\n host: HTMLElement | null\n}>()\n\nconst { Button: UButton, DropdownMenu: UDropdownMenu, Tooltip: UTooltip } = useUiKit()\n\nconst emit = defineEmits<{ hover: [hovered: boolean] }>()\n\n/** The target in the current document; null once its position holds something else (deleted, replaced). */\nconst block = computed<BlockTarget | null>(() => {\n const { editor, target } = props\n if (!target || editor.isDestroyed) return null\n const { doc } = editor.state\n if (target.pos < 0 || target.pos >= doc.content.size) return null\n const node = doc.nodeAt(target.pos)\n if (!node || node.type !== target.node.type) return null\n return effectiveTarget(editor, { node, pos: target.pos })\n})\n\n// Reading `state` makes the flag follow `setEditable` (which dispatches a transaction).\nconst editable = computed(() => {\n void props.editor.state\n return props.editor.isEditable\n})\n\nconst items = computed(() => (block.value ? blockActionsFor(props.editor, block.value, props.actions) : []))\n\nconst top = ref(0)\nconst right = ref(0)\n/** Whether the bar is docked next to the block's corner elements: `top` is then their vertical centre. */\nconst docked = ref(false)\nconst measured = ref(false)\n\nlet watched: HTMLElement[] = []\n// The corner elements can change size (a badge appears) without the document changing: watch the current ones.\nfunction watchCorner(elements: HTMLElement[]) {\n if (elements.length === watched.length && elements.every((element, index) => element === watched[index])) return\n for (const element of watched) resizeObserver?.unobserve(element)\n watched = elements\n for (const element of watched) resizeObserver?.observe(element)\n}\n\nfunction measure() {\n const { editor, host } = props\n const dom = block.value && host && !editor.isDestroyed ? editor.view.nodeDOM(block.value.pos) : null\n measured.value = dom instanceof HTMLElement\n if (!(dom instanceof HTMLElement) || !host) {\n watchCorner([])\n return\n }\n const hostRect = host.getBoundingClientRect()\n const corner = blockCornerOf(dom)\n watchCorner(corner?.elements ?? [])\n docked.value = corner !== null\n if (corner) {\n top.value = corner.centerY - hostRect.top\n right.value = hostRect.right - corner.left + CORNER_GAP\n return\n }\n const rect = dom.getBoundingClientRect()\n top.value = rect.top - hostRect.top + 2\n right.value = hostRect.right - editor.view.dom.getBoundingClientRect().right + EDGE_GUTTER\n}\n\nwatch([block, () => props.editor.state], measure, { flush: 'post' })\n\nconst visible = computed(() => editable.value && items.value.length > 0 && measured.value)\n\n// An open action menu is rendered outside the overlay (portal): the overlay counts as hovered until the menu closes.\nconst root = useTemplateRef<HTMLElement>('root')\nconst menuOpen = ref(false)\nfunction onMenuOpen(open: boolean) {\n menuOpen.value = open\n emit('hover', open || !!root.value?.matches(':hover'))\n}\nfunction onMouseLeave() {\n if (!menuOpen.value) emit('hover', false)\n}\n\n// A vanishing overlay sends no mouseleave: report it, so the host does not keep waiting for one.\nwatch(visible, (value) => {\n if (value) return\n menuOpen.value = false\n emit('hover', false)\n})\n\n// The bar follows the editor's right edge when the column resizes without the window doing so.\nlet resizeObserver: ResizeObserver | undefined\nonMounted(() => {\n window.addEventListener('resize', measure)\n window.addEventListener('scroll', measure, true)\n resizeObserver = new ResizeObserver(measure)\n resizeObserver.observe(props.editor.view.dom)\n for (const element of watched) resizeObserver.observe(element)\n})\n\nonBeforeUnmount(() => {\n window.removeEventListener('resize', measure)\n window.removeEventListener('scroll', measure, true)\n resizeObserver?.disconnect()\n watched = []\n})\n</script>\n\n<template>\n <div\n v-if=\"visible\"\n ref=\"root\"\n class=\"block-actions absolute z-10 flex gap-0.5 rounded-md bg-[var(--sk-bg)] p-0.5\"\n :style=\"{ top: `${top}px`, right: `${right}px`, transform: docked ? 'translateY(-50%)' : undefined }\"\n @mouseenter=\"emit('hover', true)\"\n @mouseleave=\"onMouseLeave\"\n >\n <template v-for=\"entry in items\" :key=\"entry.action.id\">\n <UDropdownMenu v-if=\"entry.menu\" :items=\"entry.menu\" :content=\"{ align: 'end' }\" @update:open=\"onMenuOpen\">\n <UTooltip :text=\"entry.action.label\">\n <UButton\n size=\"xs\"\n color=\"neutral\"\n variant=\"ghost\"\n :icon=\"entry.action.icon\"\n :aria-label=\"entry.action.label\"\n @mousedown.prevent\n />\n </UTooltip>\n </UDropdownMenu>\n <UTooltip v-else :text=\"entry.action.label\">\n <UButton\n size=\"xs\"\n color=\"neutral\"\n variant=\"ghost\"\n :icon=\"entry.action.icon\"\n :aria-label=\"entry.action.label\"\n @mousedown.prevent\n @click=\"entry.action.run?.(entry.ctx)\"\n />\n </UTooltip>\n </template>\n </div>\n</template>\n"],"mappings":"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"}
|
|
@@ -7,6 +7,10 @@ import { Editor } from "@tiptap/vue-3";
|
|
|
7
7
|
* targets the same block). Positioned absolutely inside `host`, a `relative` element around the editor; the composite
|
|
8
8
|
* chrome (title, description, tags) addresses the composite (`effectiveTarget`). The bar is hidden while the editor is
|
|
9
9
|
* read-only. `menu` actions open a dropdown of their items instead of running on click.
|
|
10
|
+
*
|
|
11
|
+
* A block that shows icons of its own in its top-right corner marks them with `data-block-corner` (`BLOCK_CORNER_ATTR`):
|
|
12
|
+
* the bar then docks immediately left of them, centred on their row, instead of covering them. Without such elements
|
|
13
|
+
* it sits at the editor's right edge, aligned to the block's top.
|
|
10
14
|
*/
|
|
11
15
|
type __VLS_Props = {
|
|
12
16
|
editor: Editor;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { BlockActionEntry, blockActionsFor, effectiveTarget } from "./block-actions.js";
|
|
1
|
+
import { BLOCK_CORNER_ATTR, BlockActionEntry, BlockCorner, blockActionsFor, blockCornerOf, effectiveTarget } from "./block-actions.js";
|
|
2
2
|
import { BlockUiUi, blockUiFeatureCore } from "./feature.js";
|
|
3
|
-
export { type BlockActionEntry, type BlockUiUi, blockActionsFor, blockUiFeatureCore, effectiveTarget };
|
|
3
|
+
export { BLOCK_CORNER_ATTR, type BlockActionEntry, type BlockCorner, type BlockUiUi, blockActionsFor, blockCornerOf, blockUiFeatureCore, effectiveTarget };
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { blockUiFeatureCore } from "./feature.js";
|
|
2
|
-
import { blockActionsFor, effectiveTarget } from "./block-actions.js";
|
|
3
|
-
export { blockActionsFor, blockUiFeatureCore, effectiveTarget };
|
|
2
|
+
import { BLOCK_CORNER_ATTR, blockActionsFor, blockCornerOf, effectiveTarget } from "./block-actions.js";
|
|
3
|
+
export { BLOCK_CORNER_ATTR, blockActionsFor, blockCornerOf, blockUiFeatureCore, effectiveTarget };
|
|
@@ -7,6 +7,10 @@ import { Editor } from "@tiptap/core";
|
|
|
7
7
|
* targets the same block). Positioned absolutely inside `host`, a `relative` element around the editor; the composite
|
|
8
8
|
* chrome (title, description, tags) addresses the composite (`effectiveTarget`). The bar is hidden while the editor is
|
|
9
9
|
* read-only. `menu` actions open a dropdown of their items instead of running on click.
|
|
10
|
+
*
|
|
11
|
+
* A block that shows icons of its own in its top-right corner marks them with `data-block-corner` (`BLOCK_CORNER_ATTR`):
|
|
12
|
+
* the bar then docks immediately left of them, centred on their row, instead of covering them. Without such elements
|
|
13
|
+
* it sits at the editor's right edge, aligned to the block's top.
|
|
10
14
|
*/
|
|
11
15
|
export declare function BlockActionsOverlay({ editor, target, actions, host, onHover }: {
|
|
12
16
|
editor: Editor;
|
|
@@ -2,17 +2,23 @@ import { Icon } from "../../../react/Icon.js";
|
|
|
2
2
|
import { Button } from "../../../react/ui/button.js";
|
|
3
3
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../react/ui/tooltip.js";
|
|
4
4
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "../../../react/ui/dropdown-menu.js";
|
|
5
|
-
import { blockActionsFor, effectiveTarget } from "../block-actions.js";
|
|
5
|
+
import { blockActionsFor, blockCornerOf, effectiveTarget } from "../block-actions.js";
|
|
6
6
|
import { useEffect, useLayoutEffect, useReducer, useRef, useState } from "react";
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
//#region src/features/block-ui/react/BlockActionsOverlay.tsx
|
|
9
9
|
/** Gap between the bar and the editor's right edge. */
|
|
10
10
|
const EDGE_GUTTER = 8;
|
|
11
|
+
/** Gap between the bar and the block's own corner elements it docks next to. */
|
|
12
|
+
const CORNER_GAP = 4;
|
|
11
13
|
/**
|
|
12
14
|
* The hover actions of a block, right-aligned at the editor's text column (the drag handle sits on the block's left and
|
|
13
15
|
* targets the same block). Positioned absolutely inside `host`, a `relative` element around the editor; the composite
|
|
14
16
|
* chrome (title, description, tags) addresses the composite (`effectiveTarget`). The bar is hidden while the editor is
|
|
15
17
|
* read-only. `menu` actions open a dropdown of their items instead of running on click.
|
|
18
|
+
*
|
|
19
|
+
* A block that shows icons of its own in its top-right corner marks them with `data-block-corner` (`BLOCK_CORNER_ATTR`):
|
|
20
|
+
* the bar then docks immediately left of them, centred on their row, instead of covering them. Without such elements
|
|
21
|
+
* it sits at the editor's right edge, aligned to the block's top.
|
|
16
22
|
*/
|
|
17
23
|
function BlockActionsOverlay({ editor, target, actions, host, onHover }) {
|
|
18
24
|
const [, rerender] = useReducer((count) => count + 1, 0);
|
|
@@ -35,19 +41,35 @@ function BlockActionsOverlay({ editor, target, actions, host, onHover }) {
|
|
|
35
41
|
})();
|
|
36
42
|
const items = block && editor.isEditable ? blockActionsFor(editor, block, actions) : [];
|
|
37
43
|
const [place, setPlace] = useState(null);
|
|
44
|
+
const resizeObserver = useRef(null);
|
|
45
|
+
const watched = useRef([]);
|
|
46
|
+
const watchCorner = (elements) => {
|
|
47
|
+
const current = watched.current;
|
|
48
|
+
if (elements.length === current.length && elements.every((element, index) => element === current[index])) return;
|
|
49
|
+
for (const element of current) resizeObserver.current?.unobserve(element);
|
|
50
|
+
watched.current = elements;
|
|
51
|
+
for (const element of elements) resizeObserver.current?.observe(element);
|
|
52
|
+
};
|
|
38
53
|
const measure = () => {
|
|
39
54
|
const dom = block && host && !editor.isDestroyed ? editor.view.nodeDOM(block.pos) : null;
|
|
40
55
|
if (!(dom instanceof HTMLElement) || !host) {
|
|
56
|
+
watchCorner([]);
|
|
41
57
|
setPlace(null);
|
|
42
58
|
return;
|
|
43
59
|
}
|
|
44
60
|
const hostRect = host.getBoundingClientRect();
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
top,
|
|
49
|
-
right
|
|
50
|
-
|
|
61
|
+
const corner = blockCornerOf(dom);
|
|
62
|
+
watchCorner(corner?.elements ?? []);
|
|
63
|
+
const next = corner ? {
|
|
64
|
+
top: corner.centerY - hostRect.top,
|
|
65
|
+
right: hostRect.right - corner.left + CORNER_GAP,
|
|
66
|
+
docked: true
|
|
67
|
+
} : {
|
|
68
|
+
top: dom.getBoundingClientRect().top - hostRect.top + 2,
|
|
69
|
+
right: hostRect.right - editor.view.dom.getBoundingClientRect().right + EDGE_GUTTER,
|
|
70
|
+
docked: false
|
|
71
|
+
};
|
|
72
|
+
setPlace((previous) => previous && previous.top === next.top && previous.right === next.right && previous.docked === next.docked ? previous : next);
|
|
51
73
|
};
|
|
52
74
|
useLayoutEffect(measure);
|
|
53
75
|
const visible = editor.isEditable && items.length > 0 && place !== null;
|
|
@@ -57,12 +79,15 @@ function BlockActionsOverlay({ editor, target, actions, host, onHover }) {
|
|
|
57
79
|
const run = () => latestMeasure.current();
|
|
58
80
|
window.addEventListener("resize", run);
|
|
59
81
|
window.addEventListener("scroll", run, true);
|
|
60
|
-
const
|
|
61
|
-
resizeObserver.
|
|
82
|
+
const observer = new ResizeObserver(run);
|
|
83
|
+
resizeObserver.current = observer;
|
|
84
|
+
observer.observe(editor.view.dom);
|
|
85
|
+
for (const element of watched.current) observer.observe(element);
|
|
62
86
|
return () => {
|
|
63
87
|
window.removeEventListener("resize", run);
|
|
64
88
|
window.removeEventListener("scroll", run, true);
|
|
65
|
-
|
|
89
|
+
observer.disconnect();
|
|
90
|
+
resizeObserver.current = null;
|
|
66
91
|
};
|
|
67
92
|
}, [editor]);
|
|
68
93
|
const root = useRef(null);
|
|
@@ -82,10 +107,11 @@ function BlockActionsOverlay({ editor, target, actions, host, onHover }) {
|
|
|
82
107
|
if (!visible || !place) return null;
|
|
83
108
|
return /* @__PURE__ */ jsx(TooltipProvider, { children: /* @__PURE__ */ jsx("div", {
|
|
84
109
|
ref: root,
|
|
85
|
-
className: "block-actions absolute z-10 flex gap-0.5 rounded-md
|
|
110
|
+
className: "block-actions absolute z-10 flex gap-0.5 rounded-md bg-background p-0.5",
|
|
86
111
|
style: {
|
|
87
112
|
top: `${place.top}px`,
|
|
88
|
-
right: `${place.right}px
|
|
113
|
+
right: `${place.right}px`,
|
|
114
|
+
transform: place.docked ? "translateY(-50%)" : void 0
|
|
89
115
|
},
|
|
90
116
|
onMouseEnter: () => onHover(true),
|
|
91
117
|
onMouseLeave: () => {
|