refine skills a bit
This commit is contained in:
@@ -11,3 +11,15 @@ If you're developing an application which requires changes to welshman, you'll n
|
|||||||
- Within your application directory, link all welshman dependencies _simultaneously_ (or else only one will get linked. A command that does this is: `rm -rf node_modules; npm i; cat package.json|js '.dependencies|keys[]'|grep welshman|xargs npm link`.
|
- Within your application directory, link all welshman dependencies _simultaneously_ (or else only one will get linked. A command that does this is: `rm -rf node_modules; npm i; cat package.json|js '.dependencies|keys[]'|grep welshman|xargs npm link`.
|
||||||
|
|
||||||
If you run `npm install` in your application directory, you'll need to repeat the final step above. Finally, if you're using the `editor` module, you may run into some dependency version conflicts. I recommend editing the command above to exclude the editor.
|
If you run `npm install` in your application directory, you'll need to repeat the final step above. Finally, if you're using the `editor` module, you may run into some dependency version conflicts. I recommend editing the command above to exclude the editor.
|
||||||
|
|
||||||
|
# Agent Skills
|
||||||
|
|
||||||
|
Welshman ships with agent skills for AI coding tools. Each skill covers a specific package and is auto-loaded when relevant to your current task.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add coracle-social/welshman
|
||||||
|
```
|
||||||
|
|
||||||
|
This will prompt you to select which skills to install and which agent tool to install them for. Use `-g` to install globally across all your projects instead of just the current one.
|
||||||
|
|
||||||
|
See `skills/README.md` for the full list and details on how they activate.
|
||||||
|
|||||||
+1
-32
@@ -11,34 +11,7 @@ Agent skills are Markdown instruction sets that are auto-loaded by AI coding too
|
|||||||
### Via npx (recommended)
|
### Via npx (recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx skills add jstaab/welshman
|
npx skills add coracle-social/welshman
|
||||||
```
|
|
||||||
|
|
||||||
This will prompt you to:
|
|
||||||
|
|
||||||
1. Select which skills to install from the available list
|
|
||||||
2. Select which agent(s) to install them for (Claude Code, Cursor, Cline, etc.)
|
|
||||||
|
|
||||||
### Manual (Claude Code)
|
|
||||||
|
|
||||||
Copy or symlink the skill files into your `.claude/skills/` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Copy a skill
|
|
||||||
cp welshman-net.md ~/.claude/skills/
|
|
||||||
|
|
||||||
# Or symlink the entire collection
|
|
||||||
ln -s /path/to/welshman/skills ~/.claude/skills/welshman
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scope options
|
|
||||||
|
|
||||||
By default, skills are installed project-scoped — placed in the project's `.claude/skills/` directory and committed to the repo so your whole team benefits.
|
|
||||||
|
|
||||||
Use the `-g` flag to install globally instead, making the skills available across all your projects:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx skills add -g jstaab/welshman
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available skills
|
## Available skills
|
||||||
@@ -70,7 +43,3 @@ You can also invoke a skill manually using its slash command:
|
|||||||
```
|
```
|
||||||
|
|
||||||
This is useful when you want to prime the agent with a specific skill before starting a task.
|
This is useful when you want to prime the agent with a specific skill before starting a task.
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
The source of truth for these skills is the [welshman repository](https://github.com/coracle-social/welshman). The `docs/` directory contains the underlying documentation that skills are derived from. If you find inaccuracies or want to improve coverage, please open an issue or pull request there.
|
|
||||||
|
|||||||
@@ -521,6 +521,105 @@ abort.abort()
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Using Welshman Stores Outside Svelte
|
||||||
|
|
||||||
|
All welshman stores implement the Svelte store contract: a `subscribe(callback) → unsubscribe` method where the callback fires **synchronously** with the current value on first call, then again on every change. This makes them trivially adaptable to any reactive framework — no Svelte runtime required, only the type imports.
|
||||||
|
|
||||||
|
### React
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useState, useEffect} from 'react'
|
||||||
|
import type {Readable, Writable} from 'svelte/store'
|
||||||
|
|
||||||
|
// Returns the current store value; re-renders when it changes.
|
||||||
|
function useReadable<T>(store: Readable<T>): T {
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
// subscribe fires synchronously — capture the initial value then unsub immediately
|
||||||
|
let initial!: T
|
||||||
|
store.subscribe(v => { initial = v })()
|
||||||
|
return initial
|
||||||
|
})
|
||||||
|
useEffect(() => store.subscribe(setValue), [store])
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns [currentValue, setter] — setter calls store.set directly.
|
||||||
|
function useWritable<T>(store: Writable<T>): [T, (value: T) => void] {
|
||||||
|
return [useReadable(store), store.set]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {userProfile, pubkey} from '@welshman/app'
|
||||||
|
|
||||||
|
function ProfileHeader() {
|
||||||
|
const profile = useReadable(userProfile)
|
||||||
|
const [currentPubkey, setPubkey] = useWritable(pubkey)
|
||||||
|
|
||||||
|
return <div>{profile?.name ?? currentPubkey}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SolidJS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSignal, onCleanup} from 'solid-js'
|
||||||
|
import type {Readable, Writable} from 'svelte/store'
|
||||||
|
|
||||||
|
// Returns a SolidJS accessor (getter function); updates reactively.
|
||||||
|
function useReadable<T>(store: Readable<T>): () => T {
|
||||||
|
let initial!: T
|
||||||
|
store.subscribe(v => { initial = v })() // sync capture then unsubscribe
|
||||||
|
|
||||||
|
const [value, setValue] = createSignal<T>(initial)
|
||||||
|
onCleanup(store.subscribe(v => setValue(() => v)))
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns [accessor, setter].
|
||||||
|
function useWritable<T>(store: Writable<T>): [() => T, (value: T) => void] {
|
||||||
|
return [useReadable(store), store.set]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {userProfile} from '@welshman/app'
|
||||||
|
|
||||||
|
function ProfileHeader() {
|
||||||
|
const profile = useReadable(userProfile)
|
||||||
|
return <div>{profile()?.name}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {ref, onUnmounted} from 'vue'
|
||||||
|
import type {Readable, Writable} from 'svelte/store'
|
||||||
|
|
||||||
|
function useReadable<T>(store: Readable<T>) {
|
||||||
|
let initial!: T
|
||||||
|
store.subscribe(v => { initial = v })()
|
||||||
|
|
||||||
|
const value = ref<T>(initial)
|
||||||
|
const unsub = store.subscribe(v => { value.value = v as any })
|
||||||
|
onUnmounted(unsub)
|
||||||
|
return value // use as a readonly ref
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **No Svelte runtime needed.** Only `svelte/store` types are imported. The store objects themselves ship with `@welshman/app`.
|
||||||
|
- **Welshman stores with `.get()`** (created via `withGetter`) can be read synchronously without subscribing — useful in event handlers and callbacks outside any reactive context. Most writable stores in `@welshman/app` expose `.get()`.
|
||||||
|
- **`subscribe` always fires immediately.** Unlike many observable libraries, the initial emission is synchronous, so the `useState` / `createSignal` initial value is always populated on first render.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Gotchas & Tips
|
## Gotchas & Tips
|
||||||
|
|
||||||
- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes.
|
- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes.
|
||||||
|
|||||||
+201
-148
@@ -25,7 +25,7 @@ npm install @welshman/lib @welshman/util nostr-tools nostr-editor
|
|||||||
|
|
||||||
Import the bundled CSS to get default object/suggestion styles (optional but recommended):
|
Import the bundled CSS to get default object/suggestion styles (optional but recommended):
|
||||||
|
|
||||||
```ts
|
```typescript
|
||||||
import "@welshman/editor/index.css"
|
import "@welshman/editor/index.css"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,19 +57,34 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
|
|||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char` (trigger character), `name` (ProseMirror node type name), `editor`, `search` (returns string items), and `select` (handles item selection). |
|
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. Optional: `updateSignal`, `createSuggestion`. |
|
||||||
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`; accepts optional UI customization. |
|
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. Optional: `updateSignal`, `createSuggestion`. |
|
||||||
| `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. |
|
| `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. |
|
||||||
|
|
||||||
|
**`TippySuggestion` options:**
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `char` | yes | Trigger character (e.g. `"@"`, `"~"`) |
|
||||||
|
| `name` | yes | ProseMirror node type name to insert on selection |
|
||||||
|
| `editor` | yes | The Tiptap `Editor` instance |
|
||||||
|
| `search` | yes | `(term: string) => string[]` — returns item values matching the query |
|
||||||
|
| `select` | yes | `(value: string, props) => void` — called when the user picks an item; call `props.command({...attrs})` to insert the node |
|
||||||
|
| `updateSignal` | no | A Svelte `Readable` store; when it emits, the suggestion list re-renders (use for async/reactive search results) |
|
||||||
|
| `createSuggestion` | no | `(value: string) => Element` — renders a custom DOM element for each dropdown item |
|
||||||
|
|
||||||
|
`MentionSuggestion` is a pre-wired `TippySuggestion` for nprofile nodes. It handles `select` internally (encodes the pubkey as an nprofile with relay hints from `getRelays`) so you only need to supply `editor`, `search`, and `getRelays`.
|
||||||
|
|
||||||
### Re-exports from upstream
|
### Re-exports from upstream
|
||||||
|
|
||||||
| Export | Source |
|
| Export | Source |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `Editor` | `@tiptap/core` — the editor instance class |
|
| `Editor` | `@tiptap/core` — the editor instance class |
|
||||||
| `NodeViewProps` | `@tiptap/core` — prop type for node view factories |
|
| `NodeViewProps` | `@tiptap/core` — prop type for node view factories (Tiptap's type) |
|
||||||
|
| `NodeViewRendererProps` | `@tiptap/core` — alternate props type used in `Node.create({ addNodeView })` |
|
||||||
| `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload |
|
| `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload |
|
||||||
| `FileAttributes` | `nostr-editor` — `{ file: File, … }` passed to the `upload` callback |
|
| `FileAttributes` | `nostr-editor` — `{ file: File, … }` passed to the `upload` callback |
|
||||||
| `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor |
|
| `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor; pass directly to `new Editor({ editorProps })` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,7 +92,7 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
|
|||||||
|
|
||||||
All keys are optional. Pass `false` to disable a built-in extension entirely. Pass `{ extend?, config? }` to override defaults.
|
All keys are optional. Pass `false` to disable a built-in extension entirely. Pass `{ extend?, config? }` to override defaults.
|
||||||
|
|
||||||
```ts
|
```typescript
|
||||||
type WelshmanExtensionOptions = {
|
type WelshmanExtensionOptions = {
|
||||||
bolt11?: false
|
bolt11?: false
|
||||||
breakOrSubmit?: false | { extend?, config?: BreakOrSubmitOptions }
|
breakOrSubmit?: false | { extend?, config?: BreakOrSubmitOptions }
|
||||||
@@ -103,169 +118,205 @@ type WelshmanExtensionOptions = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`fileUpload.config` requires at minimum an `upload` function. Default allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/mpeg`, `video/webm`. `immediateUpload` defaults to `true`.
|
`fileUpload.config` requires at minimum an `upload` function. The `upload` callback must return `Promise<UploadTask>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UploadResult { url: string; sha256: string; tags: string[][] }
|
||||||
|
interface UploadTask { result?: UploadResult; error?: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
Default allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/mpeg`, `video/webm`. `immediateUpload` defaults to `true`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Common Patterns
|
## Example
|
||||||
|
|
||||||
### 1. Minimal editor with submit on Mod-Enter
|
A full-featured editor factory covering file upload, @-mention and custom-trigger autocomplete, reactive node views, word count, and draft persistence.
|
||||||
|
|
||||||
```ts
|
```typescript
|
||||||
import {Editor} from "@welshman/editor"
|
import {get, writable} from "svelte/store"
|
||||||
import {WelshmanExtension} from "@welshman/editor"
|
import {Node, Extension, mergeAttributes} from "@tiptap/core"
|
||||||
|
import {Plugin, PluginKey} from "@tiptap/pm/state"
|
||||||
const editor = new Editor({
|
import type {NodeViewRendererProps} from "@tiptap/core"
|
||||||
element: document.getElementById("editor")!,
|
|
||||||
extensions: [
|
|
||||||
WelshmanExtension.configure({
|
|
||||||
submit: () => {
|
|
||||||
const content = editor.getText({blockSeparator: "\n"}).trim()
|
|
||||||
const tags = editor.storage.nostr.getEditorTags()
|
|
||||||
console.log({content, tags})
|
|
||||||
editor.chain().clearContent().run()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Chat-style editor (Enter submits)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
WelshmanExtension.configure({
|
|
||||||
submit,
|
|
||||||
extensions: {
|
|
||||||
breakOrSubmit: {
|
|
||||||
config: {aggressive: true}, // Enter submits; Shift-Enter inserts line break
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
config: {placeholder: "Send a message…"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. File upload with Blossom/NIP-96 server
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type {FileAttributes} from "@welshman/editor"
|
|
||||||
|
|
||||||
WelshmanExtension.configure({
|
|
||||||
submit,
|
|
||||||
extensions: {
|
|
||||||
fileUpload: {
|
|
||||||
config: {
|
|
||||||
upload: async (attrs: FileAttributes) => {
|
|
||||||
try {
|
|
||||||
const {uploaded, url} = await uploadFile("https://cdn.satellite.earth", attrs.file)
|
|
||||||
if (!uploaded) return {error: "Server refused the file"}
|
|
||||||
return {result: {url, tags: []}}
|
|
||||||
} catch (e) {
|
|
||||||
return {error: String(e)}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDrop() { uploading.set(true) },
|
|
||||||
onComplete() { uploading.set(false) },
|
|
||||||
onUploadError(ed, task) {
|
|
||||||
ed.commands.removeFailedUploads()
|
|
||||||
uploading.set(false)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Trigger the file picker programmatically:
|
|
||||||
editor.chain().selectFiles().run()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. @-mention autocomplete with custom node view
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {get} from "svelte/store"
|
|
||||||
import type {NodeViewProps} from "@welshman/editor"
|
|
||||||
import {WelshmanExtension, MentionSuggestion} from "@welshman/editor"
|
|
||||||
import {profileSearch, deriveProfileDisplay} from "@welshman/app"
|
|
||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
|
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
|
||||||
|
import {
|
||||||
|
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
|
||||||
|
} from "@welshman/editor"
|
||||||
|
import type {FileAttributes, UploadTask} from "@welshman/editor"
|
||||||
|
|
||||||
const CustomMentionNodeView = ({node}: NodeViewProps) => {
|
// ── Custom inline node: room reference (~) ───────────────────────────────────
|
||||||
const dom = document.createElement("span")
|
// Defines a new ProseMirror node type for inline room references.
|
||||||
dom.classList.add("tiptap-object")
|
// Register it alongside WelshmanExtension so Tiptap knows about the "roomref" name.
|
||||||
|
|
||||||
const display = deriveProfileDisplay(node.attrs.pubkey)
|
const RoomReferenceExtension = Node.create({
|
||||||
const unsub = display.subscribe($d => { dom.textContent = "@" + $d })
|
name: "roomref",
|
||||||
|
atom: true, inline: true, group: "inline", selectable: true, priority: 1000,
|
||||||
|
addAttributes: () => ({url: {default: undefined}, h: {default: undefined}}),
|
||||||
|
parseHTML: () => [{tag: 'span[data-type="roomref"]'}],
|
||||||
|
renderHTML: ({HTMLAttributes}) =>
|
||||||
|
["span", mergeAttributes(HTMLAttributes, {"data-type": "roomref"}), "~"],
|
||||||
|
renderText: ({node}) => `~${node.attrs.url ?? ""}:${node.attrs.h ?? ""}`,
|
||||||
|
addNodeView: () => ({node}: NodeViewRendererProps) => {
|
||||||
|
const dom = document.createElement("span")
|
||||||
|
dom.classList.add("room-ref")
|
||||||
|
const unsub = deriveRoomDisplay(node.attrs.url, node.attrs.h)
|
||||||
|
.subscribe(d => { dom.textContent = "~" + d })
|
||||||
|
return {
|
||||||
|
dom, destroy: unsub,
|
||||||
|
selectNode: () => dom.classList.add("room-ref--active"),
|
||||||
|
deselectNode: () => dom.classList.remove("room-ref--active"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
// ── Editor factory ────────────────────────────────────────────────────────────
|
||||||
dom,
|
|
||||||
destroy: unsub,
|
|
||||||
selectNode() { dom.classList.add("tiptap-active") },
|
|
||||||
deselectNode() { dom.classList.remove("tiptap-active") },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WelshmanExtension.configure({
|
export const makeEditor = ({
|
||||||
|
content = "" as string | object,
|
||||||
|
placeholder = "",
|
||||||
|
uploading, // optional Writable<boolean>
|
||||||
|
wordCount, // optional Writable<number>
|
||||||
|
charCount, // optional Writable<number>
|
||||||
submit,
|
submit,
|
||||||
extensions: {
|
}: {
|
||||||
nprofile: {
|
content?: string | object
|
||||||
extend: {
|
placeholder?: string
|
||||||
addNodeView: () => CustomMentionNodeView,
|
uploading?: ReturnType<typeof writable<boolean>>
|
||||||
addProseMirrorPlugins() {
|
wordCount?: ReturnType<typeof writable<number>>
|
||||||
return [
|
charCount?: ReturnType<typeof writable<number>>
|
||||||
MentionSuggestion({
|
submit: () => void
|
||||||
editor: (this as any).editor,
|
}) => {
|
||||||
search: (term: string) => get(profileSearch).searchValues(term),
|
const profileSearch = createSearch(get(profiles), {
|
||||||
getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(),
|
onSearch: searchProfiles,
|
||||||
// Optional: render a richer item in the dropdown
|
getValue: (p: any) => p.event.pubkey,
|
||||||
createSuggestion: (pubkey: string) => {
|
fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
|
||||||
const el = document.createElement("span")
|
})
|
||||||
el.textContent = pubkey.slice(0, 12) + "…"
|
|
||||||
return el
|
const editor = new Editor({
|
||||||
|
content,
|
||||||
|
editorProps,
|
||||||
|
element: document.createElement("div"),
|
||||||
|
extensions: [
|
||||||
|
RoomReferenceExtension,
|
||||||
|
WelshmanExtension.configure({
|
||||||
|
submit,
|
||||||
|
extensions: {
|
||||||
|
// Chat-style: Enter submits, Shift-Enter inserts line break
|
||||||
|
breakOrSubmit: {config: {aggressive: true}},
|
||||||
|
placeholder: {config: {placeholder}},
|
||||||
|
|
||||||
|
// File upload — upload() must return Promise<UploadTask>
|
||||||
|
fileUpload: {
|
||||||
|
config: {
|
||||||
|
upload: async (attrs: FileAttributes): Promise<UploadTask> => {
|
||||||
|
try {
|
||||||
|
const {url, sha256, tags} = await myUploadServer(attrs.file)
|
||||||
|
return {result: {url, sha256, tags}}
|
||||||
|
} catch (e) {
|
||||||
|
return {error: String(e)}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
onDrop: () => uploading?.set(true),
|
||||||
]
|
onComplete: () => uploading?.set(false),
|
||||||
|
onUploadError: (ed, _task) => {
|
||||||
|
ed.commands.removeFailedUploads()
|
||||||
|
uploading?.set(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Custom reactive nprofile node view + "@" and "~" autocomplete
|
||||||
|
nprofile: {
|
||||||
|
extend: {
|
||||||
|
addNodeView: () => ({node}: NodeViewRendererProps) => {
|
||||||
|
const dom = document.createElement("span")
|
||||||
|
dom.classList.add("mention")
|
||||||
|
const unsub = deriveProfileDisplay(node.attrs.pubkey)
|
||||||
|
.subscribe($d => { dom.textContent = "@" + $d })
|
||||||
|
return {
|
||||||
|
dom, destroy: unsub,
|
||||||
|
selectNode: () => dom.classList.add("mention--active"),
|
||||||
|
deselectNode: () => dom.classList.remove("mention--active"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
// "@" — nprofile mention; updateSignal re-renders when search index changes
|
||||||
|
MentionSuggestion({
|
||||||
|
editor: (this as any).editor,
|
||||||
|
search: term => profileSearch.searchValues(term),
|
||||||
|
getRelays: pubkey => Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||||
|
createSuggestion: pubkey => {
|
||||||
|
const el = document.createElement("span")
|
||||||
|
el.textContent = pubkey.slice(0, 12) + "…"
|
||||||
|
return el
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// "~" — custom roomref node; select must call props.command({...attrs})
|
||||||
|
TippySuggestion({
|
||||||
|
char: "~", name: "roomref",
|
||||||
|
editor: (this as any).editor,
|
||||||
|
search: term => roomSearch.searchValues(term),
|
||||||
|
select: (id, props) => {
|
||||||
|
const [url, h] = splitRoomId(id)
|
||||||
|
if (url && h) props.command({url, h})
|
||||||
|
},
|
||||||
|
createSuggestion: id => {
|
||||||
|
const el = document.createElement("span")
|
||||||
|
el.textContent = id.slice(0, 16) + "…"
|
||||||
|
return el
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
|
],
|
||||||
|
onUpdate({editor}) {
|
||||||
|
wordCount?.set(editor.storage.wordCount.words)
|
||||||
|
charCount?.set(editor.storage.wordCount.chars)
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Reading content and tags after submit
|
return editor
|
||||||
|
|
||||||
```ts
|
|
||||||
const submit = () => {
|
|
||||||
// Plain text with newlines between paragraphs
|
|
||||||
const content = editor.getText({blockSeparator: "\n"}).trim()
|
|
||||||
|
|
||||||
// NIP-10 / NIP-27 tags built from inline nostr objects
|
|
||||||
const tags = editor.storage.nostr.getEditorTags()
|
|
||||||
|
|
||||||
// Full ProseMirror JSON (for saving draft state)
|
|
||||||
const json = editor.getJSON()
|
|
||||||
|
|
||||||
// Restore from saved JSON
|
|
||||||
editor.commands.setContent(json)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Reading content on submit ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const onSubmit = (editor: Editor) => {
|
||||||
|
const content = editor.getText({blockSeparator: "\n"}).trim()
|
||||||
|
const tags = editor.storage.nostr.getEditorTags() // NIP-10 / NIP-27 tags
|
||||||
|
console.log({content, tags})
|
||||||
|
editor.chain().clearContent().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mounting in Svelte ────────────────────────────────────────────────────────
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Tracking word / character count
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import type {Editor} from "@welshman/editor"
|
||||||
|
import {onMount, onDestroy} from "svelte"
|
||||||
|
|
||||||
```ts
|
const {editor, autofocus = false}: {editor: Editor; autofocus?: boolean} = $props()
|
||||||
import {writable} from "svelte/store"
|
|
||||||
import {Editor, WelshmanExtension} from "@welshman/editor"
|
|
||||||
|
|
||||||
const wordCount = writable(0)
|
let element: HTMLElement
|
||||||
const charCount = writable(0)
|
|
||||||
|
|
||||||
const editor = new Editor({
|
onMount(() => {
|
||||||
element,
|
element.append(editor.options.element)
|
||||||
extensions: [WelshmanExtension.configure({submit})],
|
if (autofocus) {
|
||||||
onUpdate({editor}) {
|
const atEnd = editor.getText().trim().length > 0
|
||||||
wordCount.set(editor.storage.wordCount.words)
|
requestAnimationFrame(() => editor.commands.focus(atEnd ? "end" : "start"))
|
||||||
charCount.set(editor.storage.wordCount.chars)
|
}
|
||||||
},
|
})
|
||||||
})
|
|
||||||
|
onDestroy(() => editor.destroy())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={element}></div>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -292,3 +343,5 @@ const editor = new Editor({
|
|||||||
- **`getEditorTags()` returns NIP-10/27 tags.** This method on `editor.storage.nostr` collects all inline nostr objects (mentions, links, embeds) and returns the appropriate `p`, `e`, `a`, `t`, `r` tags for the published event. Always call this when building the event to publish.
|
- **`getEditorTags()` returns NIP-10/27 tags.** This method on `editor.storage.nostr` collects all inline nostr objects (mentions, links, embeds) and returns the appropriate `p`, `e`, `a`, `t`, `r` tags for the published event. Always call this when building the event to publish.
|
||||||
- **Initial content.** Pass either a plain string or a ProseMirror JSON object to `new Editor({content})`. To restore a draft, save `editor.getJSON()` and pass it back as `content`.
|
- **Initial content.** Pass either a plain string or a ProseMirror JSON object to `new Editor({content})`. To restore a draft, save `editor.getJSON()` and pass it back as `content`.
|
||||||
- **`removeFailedUploads()` command.** Call `editor.commands.removeFailedUploads()` in `onUploadError` to clean up any partially-inserted upload nodes so the composer stays in a clean state.
|
- **`removeFailedUploads()` command.** Call `editor.commands.removeFailedUploads()` in `onUploadError` to clean up any partially-inserted upload nodes so the composer stays in a clean state.
|
||||||
|
- **`addFile(file, pos)` command.** Programmatically inserts a file upload node at a given ProseMirror position — useful for native clipboard paste (e.g. mobile) where the browser paste event carries no file data.
|
||||||
|
- **`editor.commands.focus('start' | 'end' | number)`.** Pass `"end"` to place the cursor after existing content (restoring a draft), `"start"` for a fresh empty editor. Call inside `requestAnimationFrame` when the editor element was just mounted.
|
||||||
|
|||||||
Reference in New Issue
Block a user