The Threnodial Monologues
Hello friends.
Preamble
The objective for this chapter is to have a fully functional editing experience that I’m happy with. Whether or not it includes multiplayer, not sure. After the editor reaches V1, I suppose the chapter after would be about “opening” the site for others to test it and give feedback. Whoever joins can drive its development; that is, what to work on next. Perhaps the first feature requested is a feed, and it’ll be implemented, probably. Perhaps there will be no feature requests, and that’s fine too, actually.
Please don’t obligate yourself in joining… I would rather have a competitive offer than spare-change pity. It would be more helpful if you could outline what you want out of it compared to your current setup, and how it lacks, if anything. Criticize it. Tell me what’s garbage. Make fun of it on your blog… tell me how stupid I am… Most importantly, if there is nothing wrong with your current setup, why change it? Neocities is wonderful as is, so stay if it jives with you. Also, not to imply you can’t use both platforms, obviously. Not to imply you’d ever want to use this one either, obviously.
Perhaps after enough features webnook would appeal more enticing, but I understand the importance of Network Effects. There’s no point leaving the community you built here (if you managed to form some type of community…).
Alas, with that all out of the way, let’s begin.
In Search Of FileTrees
So luckily with the last chapter I’ve already procrastinated a lot by extensive research. What was realized is that you may as well implement everything yourself. Filetrees shouldn’t be too difficult! Or, rather, require a package. This is the first serious component I’ll be building in SolidJS™, so let’s see if it was a good choice.
hours
I’ve been procrastinating. I’m not sure why. I guess I always feel an initial resistance to frontend work. Not because I can’t do it, but because it’s easy to get lost in the weeds while doing it. Sometimes I could fiddle with the CSS for hours; I did so a few days ago with the rather rudimentary logo. Tried to create something more complicated, but gave up.
With backend work the goal is well defined. With frontend work, it isn’t. Maybe I should use Figma after all.
I wonder when is the last time I’ll update this neocities account. We had good times together, however distant. Don’t forget that!
I’ve decided that I’ll write after I do all the work. I think that’s probably for the better.
two days later
I’ve a working file tree now. I will not post screenshots yet, because I want it to look crisp, and that requires getting some other icon sets and etc. Right now I’m wiring up the actions.
You’d be surprised how difficult it is to capture transient states within a tree structure. To eventually support the case where you can watch your collaboration partner create & type in a filename, general file operation Presence™ (not sure if this is what I will use just yet, but likely), I needed to add more booleans to the TreeNode type.
On Programming Language Dispositions
Also, as a small reflection, working in TypeScript makes you think about all the Data first and how it flows. I never noticed this before, since Elixir is dynamic and you typically think in terms of transformations or just functions applied to an amorphous state you vaguely figure out but never define.
So, for example, let’s say you’re going to make a compiler. In TypeScript you’re best off defining all the intermediary types you’ll need as it flows through lexical analysis and ASTs and etc.
In Elixir, at least for me, one starts with the idea of src_code -> ast. And then slowly fills in all the details between.
I suppose it’s a small example on how programming languages & languages in general changes one thinking, isn’t it?
Anyway, I’ll go ahead and wire up these operations. Once that’s done, we can then wire in and beef up the editor. The editor, I can see, may take a bit more work, especially with sychronization and everything… but I think it’ll be fine.
FileTree Preparations
day later
Most of the operations are wired, I’d like to think. You’d be surprised how spaghetti it gets, and how quickly. The last remaining operation is drag’n’drop, so that’ll be fun. It might be time to pivot the actual editing experience though. Drag and drop isn’t fundamental to using a file tree. Obviously we’ll eventually support it, as in those rare moments it’s quite luxurious, though still unnecessary. Ah! Need to do renames still, too.
Additionally, so eventually one can see Presence of other collaborators, we’re programming it all “one layer removed” from the DOM. What they’re focused on, or have opened, etc. I think this adds to the spaghetti, but it’ll be nice to not have to rewrite everything when collab is tacked on.
I’ll jot down in the TODO that renames need to be supported, drag’n’drops, but I’d like to wire in the editing experience now, so let’s move on. For now we can consider this current FileTree V1.
<Transition
onEnter={(el, done) => nodeTransition(el, done, true)}
onExit={(el, done) => nodeTransition(el, done, false)}
>
<Show when={isVisible(node)}>
<div
onClick={() => handleTreeNodeClick(node)}
tabIndex={-1}
data-node-id={node.id}
class="px-2 text-sm focus:bg-base-200 py-0.5 bg-base-100 hover:bg-base-300 flex items-center dark:text-slate-400 text-slate-600 gap-x-1"
style={{ "padding-left": `${node.depth * 16}px` }}
>
<span class={`ph ${getNodeIcon(node)} shrink-0 size-4.5`}></span>
{node.isEditing ? (
<input
class="w-fit"
value={editor.getDraft(node.id) ?? ""}
autofocus
use:autofocus
onInput={(e) => editor.updateRenameDraft(node.id, e.currentTarget.value)}
onBlur={() => editor.commitNode(node.id)}
onKeyDown={(e) => e.key === "Enter" && editor.commitNode(node.id)}
/>
) : (
basename(node.path)
)}
</div>
</Show>
</Transition>
It’s hard to say if bundling all the signals together under an editor object is the blessed way, but it’s working at least.
Frankly the editor store could probably be broken up into three separate stores, or just all be toplevel, but in due time.
Intermissions: SolidJS reflections
After building out a Serious Component™ with SolidJS I have to say… it’s not necessarily that different from React. Or rather, at least how I structure the data, I guess. I wish there was more documentation on dealing with Maps and Sets as signals, or at least helpers, but that’s fine.
In a strange way it makes me more appreciative of React, though I think that appreciation will plummet upon working more with CodeMirror and freed from the React Runtime constraints.
It is nice to think about the DOM rather than React’s Wild Ride on lifecycles and all. The reactivity of Solid still is mildly confusing, because it seems like there is more compiler magic, but otherwise I’ve run into no serious hangups. You can either choose compiler magic or runtime magic; I like the former. Right now mostly everything is stuffed into a large “store” object, but perhaps, in due time, it’ll be moved into a Context, or it will be top-level imports. Perhaps the next venture will inform it. Let’s equip our satchel & lantern and begin.
Crossing the CodeMirror Chasm
lunch break
Alrighty, it’s the Big Event. Not really.
For a high overview, we’ll do the same strategy we did last time, mostly. There’ll be a FileBuffer type and we’ll store a Record<NodeId, FileBuffer> to easily fetch a buffer if it exists for the clicked on file, or fetch the contents and create a buffer.
The more we interact with blobs the more I want to set up a backend cache… but let’s save optimizations for another day.
After that we store the contents within the FileBuffer on every change, I imagine, and that’s that.
Of course because we’re not handing everything off to YJS we’ll need to dig more into the internals of CodeMirror, but when it happens it happens.
Server-Reconciliation Detours
day later
So, for whatever reason I pivoted from building out the editor today and instead reimplemented a library in Elixir to use for collaboration.
The current implementation follows the “simple” design which will have somewhat horrible performance as the document grows, but the point is that it’s now all implemented to try out. Reusing the testcases they had was helpful toward this end, and this is the whole point of this implementation: being able to understand and control how collab works, rather than delegate it all to YJS and learn YJS internals and etc.
For example, by taking this detour, one day I could implement “delete guards” for blocks within your editor. Only those who wrote in the character can delete the character. I think such a thing would be extremely difficult with YJS. Not to imply anyone wants such guards anyway.
I’ll share more of the library as I push forward, but first I need to validate if this is a viable path. Perhaps I ought to bend the knee to the opaqueness of CRDTs, but since this only took a few hours, why not.
The objective still remains: actually getting the buffers to work. Ideally wire in tabs right after. Once this is all done, then we can try setting up collab through this server-reconciliation library.
So… back to the editor.
Back To Editor
two days later
I’m not sure if it was a day or two days, but I got lost in doing something else I guess.
Anyway, most of it is wired up, I think. Well, I need to implement saving. All of the papercuts can be dealt with later, as I work on recreating siqu.neocities.org using only webnook.
It can be a small grind. I figure we’re maybe 40% of the way there to fully validating the collab and editor experience. But right now you can click on a file, and it’ll fetch the contents and open a new tab. I groaned awhile wondering whether to support multiple tabs per buffer, for the rare chance you want to work on a long file in different spots. I figure may as well pull the bandaid now, even if it’s just an edgecase. I hope the way I load the tab in such cases doesn’t cause race conditions. Surely it shouldn’t right? It depends on how the server reconciliation works… but I think it’ll be fine.
Let’s see… I think the next step is still saving and loading.
day later
So I was getting lost in the weeds of implementing collab again. It’s all sorted, I think, at least. There are a few doubts, but it’ll dissipate upon first stitching.
Realistically I should just make the editor work for singleplayer and devote another section to multiplayer. But who knows, maybe it’s not that difficult to do today…
Starting seems to usually be the hardest part. The trick is to do a completely mundane task, like wiring in what’s needed to close a tab. That starts it all, but I wish I had a more tangible demo to reach for. Maybe after fixing how it looks more it’ll be easier.
few hours
On one hand, I’ve technically reached a good checkpoint. The editors are linked to tabs, we built out the file tree this chapter, too, but I wish I could push a little more forward and make it look nice, and maybe even collaborative out of the gate.
Programming for design and then programming for correctness are so far apart from each other…
Yeah, I’m just going to take a break for now. Maybe I’ll work on it later today… or not.
hours
I realized that a good checkpoint, axing the declaration at the beginning of this, is to be able to type and save and have that update propagate to your site.
Such an obvious checkpoint! Of course, unfortunately there will be no screenshots in this chapter, but oh well.
So all I need to do is implement autosave. The design and collab can come next chapter, maybe, I don’t know. Whenever the next push to work comes I can see it covering collab too.
It’s not me that makes this thing. It uses my hands, of course, but that’s it. Sometimes I’m called and the details are filled in. For the most part, it happens, and I watch it.
The only time I attend is perhaps during researching, which can be exhausting… depending.
hour or two
Autosave is implemented. I’m not sure why such a simple change was such a blocker:
function handleTransaction(fileId: NodeId, tr: Transaction, view: EditorView) {
if (!tr.docChanged) return
editor.setBuffers(fileId, { isDirty: true })
const content = view.state.doc.toString()
editor.saveFile(fileId, content)
}
// editor_store.ts
const saveFile = debounce(async (fileId: NodeId, content: string) => {
const meta = getChannel(`site_meta:${subdomain()}`)
meta
.push("file:save", { id: fileId, content })
.receive("ok", (content) => setBuffers(fileId, { isDirty: false, content }))
}, 1000)
Later the “content” will be saved serverside, probably. The debounce would be moved to the server. That’s probably why it’s difficult at times. I keep wondering about the “final” architecture instead of getting singleplayer working. There’ll be a live process holding the changes. Ideally this will also be referenced in the preview.
Anyway, we’ve reached the demo.
When I type in <h1>hello again</h1> it autosaves and shows up under first.nook.test/hello.html.
Refreshing the editor, when I click on hello.html again, it pulls the content and displays it.
It’d be nice to have some indicator to show when autosaving is happening… and also allow explicit saving, depending. Not sure yet.
To recap, we’ve rebuilt the filetree, tabs, and wired in the core editor, though slightly improved to later handle other players. We also built out the collaboration library in Elixir.
In the next chapter we’ll dive into the collaboration chronicles, and maybe add some editor polish.
Until next time!