- updated prefilled doc

- refactor separated editor syles and schema into separate components
This commit is contained in:
miggymofongo 2025-01-08 15:12:26 -04:00
parent 468a454323
commit 8abd046aff
5 changed files with 351 additions and 282 deletions

View file

@ -2,26 +2,31 @@
<p> <p>
Aren't you tired of distracting social media platforms? Aren't you tired of distracting social media platforms?
So many buttons and advertising makes a simple task of reading So many buttons and advertising makes a simple task of
and writing notes to your community very draining. You can use reading and composing notes to your community very draining. You can use
a next generation social media protocol to transform a next generation social media protocol to transform
your personal website into a micro social media client. your personal website into a micro social media client
that will clear up space on your phone for media. </p>
Use a library like <a href="https://prosemirror.net/">ProseMirror</a>,
<p>
I'm using <a href="https://prosemirror.net/">ProseMirror</a>,
to build a WYSIWYM style rich text editor for visitors to build a WYSIWYM style rich text editor for visitors
to compose notes with. They can then post it to a set of to compose notes with. I'm taking inspiration from tumblr
programmed relays. and medium's text editors to build something minimal and
intuitive that will run easily via browsers.
</p> </p>
<p>This is an installable personal website that is accessible through chrome-based and firefox browsers. The website is built with PWA Builder and Lit Web Components. It utilizes the Nostr <p>I scaffolded the project with PWA Builder and am using
protocol to fetch profile metadata, short text, and long form notes (event kinds 0, 1, and 30023) from a relay. </p> Lit to power up my Web Components. The website utilizes
the Nostr protocol to sign in via browser extension to
fetch profile metadata and pull event kinds 0, 1, and 30023
from a relay. </p>
<p>If you're on any chrome-based, firefox or safari browser try visiting the webpage then tapping on the arrow <p>If you're on any chrome-based, firefox or safari browser try visiting the webpage then tapping on the arrow pointing up in the bottom toolbar. Scroll down a bit to tap on "Add to Home Screen".
pointing up in the bottom toolbar. Scroll down a bit to tap on "Add to Home Screen".
If you're on a chromium-based browser you should be able to do the same.</p> If you're on a chromium-based browser you should be able to do the same.</p>

157
src/components/schema.ts Normal file
View file

@ -0,0 +1,157 @@
// custom-schema.ts
import { Schema, NodeSpec, MarkSpec } from "prosemirror-model";
// Define custom nodes
export const nodes = {
text: {
group: "inline",
} as NodeSpec,
star: {
inline: true,
group: "inline",
toDOM() {
return ["star", "⭐"];
},
parseDOM: [{ tag: "star" }],
} as NodeSpec,
paragraph: {
group: "block",
content: "inline*",
toDOM() {
return ["p", 0];
},
parseDOM: [{ tag: "p" }],
} as NodeSpec,
boring_paragraph: {
group: "block",
content: "text*",
marks: "",
toDOM() {
return ["p", { class: "boring" }, 0];
},
parseDOM: [{ tag: "p", priority: 60 }],
} as NodeSpec,
blockquote: {
content: "block+",
group: "block",
parseDOM: [{ tag: "blockquote" }],
toDOM() {
return ["blockquote", 0];
},
} as NodeSpec,
horizontal_rule: {
group: "block",
selectable: false,
parseDOM: [{ tag: "hr" }],
toDOM() {
return ["hr"];
},
} as NodeSpec,
heading: {
attrs: { level: { default: 1 } },
content: "inline*",
group: "block",
defining: true,
parseDOM: [
{ tag: "h1", attrs: { level: 1 } },
{ tag: "h2", attrs: { level: 2 } },
{ tag: "h3", attrs: { level: 3 } },
],
toDOM(node) {
return ["h" + node.attrs.level, 0];
},
} as NodeSpec,
code_block: {
content: "text*",
group: "block",
marks: "",
defining: true,
code: true,
parseDOM: [{ tag: "pre", preserveWhitespace: "full" }],
toDOM() {
return ["pre", ["code", 0]];
},
} as NodeSpec,
image: {
inline: true,
attrs: {
src: {},
alt: { default: null },
title: { default: null },
},
group: "inline",
draggable: true,
parseDOM: [
{
tag: "img[src]",
getAttrs(dom: any) {
return {
src: dom.getAttribute("src"),
alt: dom.getAttribute("alt"),
title: dom.getAttribute("title"),
};
},
},
],
toDOM(node) {
return ["img", node.attrs];
},
} as NodeSpec,
doc: {
content: "block+",
} as NodeSpec,
};
// Define custom marks
export const marks = {
shouting: {
toDOM() {
return ["shouting", 0];
},
parseDOM: [{ tag: "shouting" }],
} as MarkSpec,
link: {
attrs: { href: {} },
toDOM(node) {
return ["a", { href: node.attrs.href }, 0];
},
parseDOM: [
{
tag: "a",
getAttrs(dom) {
return { href: dom.getAttribute("href") };
},
},
],
inclusive: false,
} as MarkSpec,
bold: {
parseDOM: [
{ tag: "strong" },
{ tag: "b", getAttrs: () => null },
{
style: "font-weight",
getAttrs: (value: string) => (value === "bold" ? null : false),
},
],
toDOM() {
return ["strong", 0];
},
} as MarkSpec,
emphasis: {
parseDOM: [
{ tag: "em" },
{ tag: "i", getAttrs: () => null },
{
style: "font-style",
getAttrs: (value: string) => (value === "italic" ? null : false),
},
],
toDOM() {
return ["em", 0];
},
} as MarkSpec,
};
// Export the schema
export const customSchema = new Schema({ nodes, marks });

View file

@ -1,4 +1,4 @@
import { LitElement, html, css } from 'lit'; import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js'; import { customElement } from 'lit/decorators.js';
import { styles } from './about-styles'; import { styles } from './about-styles';
@ -11,9 +11,7 @@ export class AppAbout extends LitElement {
static styles = [ static styles = [
sharedStyles, sharedStyles,
styles, styles,
css`
`
] ]
connectedCallback(): void { connectedCallback(): void {
@ -31,10 +29,21 @@ export class AppAbout extends LitElement {
<h3>What am I looking at?</h3> <h3>What am I looking at?</h3>
<p> <p>
This is a personal micro-social media client that can be installed to This is a personal PWA installable
your desktop or smartphone home screen. You can sign in via an extension to any home screen built with web
to display your profile data and notes from my relay. You components, the nostr protocol,
can compose a note using a rich text editor I built with Prosemirror. and ProseMirror.
<h3> What can I do with this?</h3>
<ul><li>You can use it to sign in with a
nostr identity and display your
profile data. </li><li>Visit the note
wall to view recent notes from my relay.</li>
<li>You can also compose a note using a rich
text editor I built with Prosemirror. </li>
Keep a folder of your top friends on
your home screen to show off and use to
leave them notes.</li></ul>
</p> </p>
@ -43,7 +52,12 @@ export class AppAbout extends LitElement {
<p>Look for "Add to Home Screen" in your browser toolbar <p>Look for "Add to Home Screen" in your browser toolbar
to install it to your homescreen. </p> to install it to your homescreen. </p>
<h3>What is Nostr?</h3> <h3>What is ProseMirror</h3>
<p>ProseMirror is the rich text editor library under the
hood of Tip Tap. It seems overwhelming at first because
it's basically a lego set, but it gives you a cool
amount of control over the end product.</p>
<h3>What is nostr?</h3>
<p>Notes and Other Stuff Transmitted Over Relays is a simple open source <p>Notes and Other Stuff Transmitted Over Relays is a simple open source
social media protocol that enables anybody to implement social media functionalities social media protocol that enables anybody to implement social media functionalities
into their websites.</p> into their websites.</p>

View file

@ -1,4 +1,4 @@
import { LitElement, css, html } from 'lit'; import { LitElement, html } from 'lit';
import { customElement, query } from 'lit/decorators.js'; import { customElement, query } from 'lit/decorators.js';
import '@shoelace-style/shoelace/dist/components/card/card.js'; import '@shoelace-style/shoelace/dist/components/card/card.js';
@ -6,122 +6,35 @@ import '@shoelace-style/shoelace/dist/components/card/card.js';
import '@shoelace-style/shoelace/dist/components/button/button.js'; import '@shoelace-style/shoelace/dist/components/button/button.js';
import { styles } from '../../styles/shared-styles'; import { styles } from '../../styles/shared-styles';
import { editorStyles } from './write-styles';
import {EditorState} from 'prosemirror-state' import {EditorState} from 'prosemirror-state'
import { Transaction } from 'prosemirror-state'; import { Transaction } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view'; import { EditorView } from 'prosemirror-view';
import { Schema } from 'prosemirror-model'; import { customSchema } from '../../components/schema';
import { keymap } from 'prosemirror-keymap'; import { keymap } from 'prosemirror-keymap';
import { toggleMark } from 'prosemirror-commands'; import { toggleMark } from 'prosemirror-commands';
import {undo, redo, history} from 'prosemirror-history' import {undo, redo, history} from 'prosemirror-history'
import { baseKeymap } from 'prosemirror-commands'; import { baseKeymap } from 'prosemirror-commands';
/* i begin by creating a custom schema. following the guide // function for error reports
on prosemirror.net, i created a basic schema with a few
types of nodes.
texts are
*/
export const customSchema = new Schema({
nodes: {
text: {
group: 'inline',
},
star: {
inline: true,
group: 'inline',
toDOM() {
return ['star', '⭐'];
},
parseDOM: [{ tag: 'star' }],
},
paragraph: {
group: 'block',
content: 'inline*',
toDOM() {
return ['p', 0];
},
parseDOM: [{ tag: 'p' }],
},
boring_paragraph: {
group: 'block',
content: 'text*',
marks: '',
toDOM() {
return ['p', { class: 'boring' }, 0];
},
parseDOM: [{ tag: 'p', priority: 60 }],
},
hr: {
group: 'block',
selectable: true,
parseDOM: [{ tag: 'horizontal_rule' }],
toDOM() {
return ['hr', { class: 'horizontal-rule'}]
}
},
doc: {
content: 'block+',
},
},
marks: {
shouting: {
toDOM() {
return ['shouting', 0];
},
parseDOM: [{ tag: 'shouting' }],
},
link: {
attrs: { href: {} },
toDOM(node) {
return ['a', { href: node.attrs.href }, 0];
},
parseDOM: [
{
tag: 'a',
getAttrs(dom) {
return { href: dom };
},
},
],
inclusive: false,
},
bold: {
},
emphasis: {
}
},
});
// function for error reports
function errorReport(message: string): void { function errorReport(message: string): void {
console.error(message); console.error(message);
} }
/* COMMANDS
commands are functions that take an editor state
and a dispatch function and returns a boolean
/* COMMANDS to implement editing actions.*/
commands are functions that take an editor state
and a dispatch function and returns a boolean
to implement editing actions.*/
/**this command inserts a star by your cursor. it sets /**this command inserts a star by your cursor. it sets
* type to the star node, and creates one when * type to the star node, and creates one when
* dispatch is provided */ * dispatch is provided */
function insertStar(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { function insertStar(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
const star = customSchema.nodes.star; const star = customSchema.nodes.star;
const { $from } = state.selection; const { $from } = state.selection;
@ -140,10 +53,10 @@ function insertStar(state: EditorState, dispatch?: (tr: Transaction) => void): b
return true; return true;
} }
/* function that inserts an <hr> line break /* function that inserts an <hr> line break*/
*/
function insertHR(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { function insertHR(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
const hr = customSchema.nodes.hr; // Access the HR node from the schema const hr = customSchema.nodes.horizontal_rule; // attach the HR node from the schema to a variable
if (!hr) { if (!hr) {
console.error('HR node is not defined in the schema.'); console.error('HR node is not defined in the schema.');
return false; return false;
@ -155,32 +68,69 @@ function insertHR(state: EditorState, dispatch?: (tr: Transaction) => void): boo
return true; return true;
} }
/**command function that inserts a blockquote */
function insertQuote(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
console.log("you just put a blockquote");
const { schema, tr, selection } = state;
const blockquote = schema.nodes.blockquote;
// Handle when no selection is made (empty selection)
if (selection.empty) {
console.log("No selection, inserting a blockquote...");
const blockquoteNode = blockquote.createAndFill();
if (blockquoteNode) {
// Only dispatch if `dispatch` is defined
if (dispatch) {
dispatch(tr.replaceSelectionWith(blockquoteNode));
}
}
} else {
console.log("Inserting blockquote at selection");
// Handle the case where there is a selection
const blockquoteNode = blockquote.createAndFill();
if (blockquoteNode) {
// Only dispatch if `dispatch` is defined
if (dispatch) {
dispatch(tr.replaceSelectionWith(blockquoteNode));
}
}
}
return true;
}
/* this command will apply the link mark to hyperlink your selection */
/* this command will prompt you for a url, which will
apply link mark to your selection and make your selection a hyperlink */
function toggleLink(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { function toggleLink(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
let {doc, selection} = state let {doc, selection} = state;
if (selection.empty) return false if (selection.empty) return false;
let attrs = null let attrs = null
if (!doc.rangeHasMark(selection.from, selection.to, customSchema.marks.link)) { if (!doc.rangeHasMark(selection.from, selection.to, customSchema.marks.link)) {
attrs = {href: prompt("Link to where?", "")} attrs = {href: prompt("Paste your URL in here please", "")}
if (!attrs.href) return false if (!attrs.href) return false
} }
return toggleMark(customSchema.marks.link, attrs)(state, dispatch) return toggleMark(customSchema.marks.link, attrs)(state, dispatch)
} }
/* these functions add the bold and emphasis marks */
function toggleBold(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
return toggleMark(customSchema.marks.bold)(state, dispatch);
}
function toggleEmphasis(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
return toggleMark(customSchema.marks.emphasis)(state, dispatch)
}
// custom keymap to apply to state // custom keymap to apply to state
const customKeymap = keymap({ const customKeymap = keymap({
'Ctrl-Shift-Space': insertStar, 'Shift-Space': insertStar,
'Ctrl-b': (state, dispatch) => { 'Ctrl-g': (state, dispatch) => {
console.log("Ctrl-b pressed, toggling shouting mark..."); console.log("Ctrl-b pressed, toggling shouting mark...");
return toggleMark(customSchema.marks.shouting)(state, dispatch); return toggleMark(customSchema.marks.shouting)(state, dispatch);
}, },
@ -188,17 +138,15 @@ const customKeymap = keymap({
console.log("you should have just gotten an alert to place a url into a hyperlink"); console.log("you should have just gotten an alert to place a url into a hyperlink");
return toggleLink(state, dispatch); return toggleLink(state, dispatch);
}, },
'Ctrl-e': toggleEmphasis,
'Ctrl-b': toggleBold,
'Ctrl-Shift-h': insertHR, 'Ctrl-h': insertHR,
'Ctrl-q': insertQuote
},
});
);
@customElement('app-write') @customElement('app-write')
export class AppWrite extends LitElement { export class AppWrite extends LitElement {
@ -212,76 +160,44 @@ export class AppWrite extends LitElement {
static styles = [ static styles = [
styles, styles,
css` editorStyles
.ProseMirror {
background: black;
color: white;
background-clip: padding-box;
padding: 5px 0;
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
padding: 1rem;
line-height: 1.2;
outline: none;
font-family: var(
--markdown-editor-typography-font-family,
var(--mdc-typography-font-family, Montserrat, sans-serif)
);
font-size: var(
--markdown-editor-typography-font-size,
var(--mdc-typography-subtitle1-font-size, 1rem)
);
font-weight: var(
--markdown-editor-typography-font-weight,
var(--mdc-typography-subtitle1-font-weight, 400)
);
letter-spacing: var(
--markdown-editor-typography-letter-spacing,
var(--mdc-typography-subtitle1-letter-spacing, 0.009375em)
);
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror a {
color: var(--markdown-editor-typography-anchor-color, -webkit-link);
text-decoration: var(--markdown-editor-typography-anchor-text-decoration);
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
shouting {
all: unset; /* Remove inherited or conflicting styles */
font-weight: bold;
text-transform: uppercase;
color: red; }
.boring {
background: grey;
}
`
]; ];
constructor() {
protected async firstUpdated() { super();
console.log("Welcome to the compose page");
await this.updateComplete;
console.log('Rendered HTML:', this.shadowRoot?.innerHTML);
this.initializeEditor()
if (!this.editorContainer) {
errorReport('Editor container not here');
return;
}
} }
destroy() {
this.editorView.destroy
}
connectedCallback(): void {
super.connectedCallback();
console.log('AppWrite added to the DOM')
}
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.editorView) {
this.editorView.destroy();
}
}
protected async firstUpdated() {
console.log("Welcome to the compose page");
await this.updateComplete;
this.initializeEditor()
if (!this.editorContainer) {
errorReport('Editor container not here');
return;
}
}
private initializeEditor() { private initializeEditor() {
if (!this.editorContainer) { if (!this.editorContainer) {
@ -289,84 +205,75 @@ export class AppWrite extends LitElement {
return; return;
} }
/* this is a schema of initial content to pre-populate the editor /* a prepopulated document of initial content to guide the user */
with guidance */
let doc = customSchema.node("doc", null, [ let doc = customSchema.node('doc', null, [
customSchema.node("paragraph", null, [customSchema.text("write anything")]), customSchema.node('heading', null, [customSchema.text('Título')]),
customSchema.node("boring_paragraph", null, [customSchema.text("you can't apply any marks to text in this boring paragraph")])
customSchema.node('blockquote', null, [
customSchema.node('paragraph', null, [customSchema.text('"WEPA"')]),
]),
customSchema.node('paragraph', null, [customSchema.text('Escribe algo')]),
customSchema.node('boring_paragraph', null, [
customSchema.text('no se puede marcar ningún texto en párrafos aburridos como este. mira el <hr> debajo de este párafo. puedes colocar uno con Ctrl + h'),
]),
customSchema.node('horizontal_rule', null),
customSchema.node('paragraph', null, [customSchema.text('Escribe mas aquí...')]),
customSchema.node('paragraph', null, [customSchema.text('Presiona Shift + Space para colocar una estrella')]),
customSchema.node('paragraph', null, [customSchema.text('Presiona Ctrl + g para marcar la selección con la marca "shouting" ')])
])
]) if (!doc) {
if (!doc) {
console.error("failed to create initial document") console.error("failed to create initial document")
return; return;
} }
/* here i create a state with doc as the schema, /* here i create a state with doc to,
which includes a couple */ which places the prepopulated document of content
and include history and keymap as plugins
*/
const state = EditorState.create({ const state = EditorState.create({
doc: doc, doc: doc,
plugins: [ plugins: [
history(), history(),
keymap({ keymap({
'Mod-z': undo, 'Mod-z': undo,
'Mod-y': redo, 'Mod-y': redo,
}), }),
customKeymap, customKeymap,
keymap(baseKeymap), keymap(baseKeymap),
], ],
}); });
/* this */
this.editorView = new EditorView(this.editorContainer, {
let view = new EditorView(this.editorContainer, { state,
state, dispatchTransaction: (transaction) => {
dispatchTransaction(transaction) { console.log('Document size went from', transaction.before.content.size, "to",
console.log('Document size went from', transaction.before.content.size, "to", transaction.doc.content.size
transaction.doc.content.size )
) let newState = this.editorView.state.apply(transaction);
let newState = view.state.apply(transaction) this.editorView.updateState(newState);
view.updateState(newState)
}
});
console.log(state)
} }
});
console.log(state)
connectedCallback(): void { }
super.connectedCallback();
console.log('AppWrite added to the DOM')
}
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.editorView) {
this.editorView.destroy();
this.editorView = null!
}
}
protected render() { protected render() {
return html` return html`
<main><app-header ?enableBack="${true}"></app-header> <main><app-header ?enableBack="${true}"></app-header>
<div class="ProseMirror"></div> <div class="ProseMirror"></div>
</main> </main>

View file

@ -1,13 +1,10 @@
import { css } from "lit" import { css } from "lit"
export const editorStyles = css` { export const editorStyles = css`
:host .ProseMirror {
background: black;
color: white;
.ProseMirror {
background: white;
color: black;
background-clip: padding-box; background-clip: padding-box;
padding: 5px 0; padding: 5px 0;
position: relative; position: relative;
@ -36,7 +33,7 @@ export const editorStyles = css` {
); );
} }
.ProseMirror pre { .ProseMirror pre {
white-space: pre-wrap; white-space: pre-wrap;
} }
@ -48,8 +45,7 @@ export const editorStyles = css` {
.ProseMirror-focused .ProseMirror-gapcursor { .ProseMirror-focused .ProseMirror-gapcursor {
display: block; display: block;
} }
shouting {
shouting {
all: unset; /* Remove inherited or conflicting styles */ all: unset; /* Remove inherited or conflicting styles */
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
@ -58,21 +54,11 @@ export const editorStyles = css` {
.boring { .boring {
background: grey; background: grey;
} }
blockquote {
.plus { font-style: italic;
position: absolute; border-left: 2px solid gray;
padding-left: 10px;
padding: 8px; color: darkgray;
background-color: #4CAF50;
color: red;
border: none;
cursor: pointer;
}
.plus:hover {
background-color: #45a049;
}