为typink编辑器构建UI界面时学到的一些经验
/ 11 min read
Table of Contents
上周花了两天时间弄完了 typ.ink 编辑器的原型,但是这个原型比较丑,因为编辑器界面我是用 daisyui 的 Divider layout 做的,这个布局是左右结构,正好左边放编辑器,右边展示预览结果,但是呢,很显然只有桌面电脑上展示是正确的,在手机上就不行了,因为没有那么宽的屏幕。因此这周末我又参考了一下别的在线编辑器的UI示例,主要是 replit,觉得他们的这个界面做得简洁直观,所以想模仿一下。编辑界面如下图:

当然,UI组件库还是用 daisyui,不打算换了,虽然这个库缺少很多常用的组件,但是它本身不包含js,我很喜欢这种设计,尽可能用html + css来渲染界面,只有非不得已再引入js。
实现 sidebar
模仿 replit UI 遇到的第一个问题就是如何实现一个可以折叠的sidebar,这个功能在很多UI库里都是内置提供的,但是daisyui没有提供,因为要点击一下按钮就展示 sidebar,再点一下就隐藏需要用到js,不过作者给了一个示例的代码教我们怎么做,也很简单,见:https://svelte.dev/playground/1117baf37a8a4c8299349d21c68039a2?version=5.17.3
自己用 svelte 5 稍微改改就是:
<script> let isSidebarExpanded = $state(false);</script>
<div class="drawer drawer-open"> <input id="my-drawer" type="checkbox" class="drawer-toggle" /> <div class="drawer-content"> <!-- Page content here --> Content </div> <div class="drawer-side"> <label for="my-drawer" aria-label="close sidebar" class="drawer-overlay" ></label> <aside class={`menu p-4 min-h-full bg-base-200 text-base-content ${isSidebarExpanded ? "w-fit" : ""}`} > <div class="flex justify-end"> <button class="btn btn-ghost btn-square" onclick={() => (isSidebarExpanded = !isSidebarExpanded)} > {#if isSidebarExpanded} ← {:else} 🟰 {/if} </button> </div> <ul> <li> <a href="/"> 😀 {#if isSidebarExpanded} Sidebar Item 1 {/if} </a> </li> <li> <a href="/"> 😀 {#if isSidebarExpanded} Sidebar Item 2 {/if} </a> </li> </ul> </aside> </div></div>
实现 contextmenu
基本的 sidebar 做完后,遇到第二个问题,就是 replit 里的sidebar如果按下右键是会弹出一个菜单栏的,用户可以在里面选择新建文件、新建文件夹等选项。很多UI组件库也内置了这个功能,但是同样 daisyui 也没有,我看到github上有人给作者反馈这个问题,同样作者回复说这个功能的实现要依赖js,所以没有内置,但是他给了一个自己实现的例子,见:https://svelte.dev/playground/bb1295931880414a9448901e6548775a?version=5.17.3
实现思路很明确,就是让一个按钮监听 contextmenu
的事件,如果触发了这个事件就展示菜单,因为这个菜单是相对鼠标位置定位的,所以需要一点js来计算菜单的位置,如果点击页面其他地方就隐藏这个菜单。
<script> let isOpen = $state(false); let position = $state([0, 0]); function showMenu(event) { event.preventDefault(); position = [event.clientX, event.clientY]; isOpen = true; } function hideMenu() { isOpen = false; }</script><svelte:window onclick={hideMenu} onblur={hideMenu} />
<button class="btn m-20" oncontextmenu={showMenu}>Right-click me </button>
{#if isOpen} <ul class="menu absolute bg-base-100 shadow-xl rounded-box w-56" style="left: {position[0]}px; top: {position[1]}px;" > <li><button>Item 1</button></li> <li><button>Item 2</button></li> </ul>{/if}
实现预览界面
在之前的左右结构的界面中,我是将屏幕右半部分作为预览界面的,现在要考虑到移动端的话,预览一个pdf就应该用弹窗,这样才可能占用整个手机的屏幕,做弹窗比较简单,就是用 Modal 就行。官方文档内置了这个组件
<!-- Open the modal using ID.showModal() method --><button class="btn" onclick="my_modal_2.showModal()">open modal</button><dialog id="my_modal_2" class="modal"> <div class="modal-box"> <h3 class="text-lg font-bold">Hello!</h3> <p class="py-4">Press ESC key or click outside to close</p> </div> <form method="dialog" class="modal-backdrop"> <button>close</button> </form></dialog>
我因为要在modal里面展示pdf,还要自定义modal的最大宽度和高度,这块官方文档也有教程。
实现文件上传按钮
上面做完了 contextmenu
后,已经可以展示右键的菜单了,我需要在这个菜单项里加上一个上传图片的功能,因为 typst 编译的时候可以包含指定路径的图片,大家写文档的时候也经常要插入图片进去,一般编辑器内上传都是直接拖拽或者粘贴图片进去,但是这里我试验一下通过右键菜单触发文件选择,然后在选择完文件后立即调用上传接口进行文件上传。
这个功能的实现也要依赖js,一步一步触发,大致代码如下:
<script lang="ts">let fileUploader: HTMLInputElement | undefined = $state();let uploaderFormButton: HTMLButtonElement | undefined = $state();
async function handleFileChange(event: Event) { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { uploaderFormButton?.click(); }}</script>
{#if isOpen} <ul class="menu absolute bg-base-100 shadow-xl rounded-box" style="left: {position[0]}px; top: {position[1]}px;" > <li> <button onclick={() => fileUploader?.click()} >Upload Images</button > </li> </ul>{/if}<form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance={() => { return async ({ update, result }) => { if (result.type === "success") { files = [ ...files, ...(result.data!.files as string[]), ]; } await update({ reset: true }); await applyAction(result); }; }}> <input id="file-input" type="file" accept="image/*" name="images" multiple hidden bind:this={fileUploader} onchange={handleFileChange} /> <button bind:this={uploaderFormButton} type="submit" hidden >Upload</button ></form>
上面的 <ul>
就是 contextmenu
打开后的菜单列表,里面有一个上传按钮
- 点击上传图片的按钮时,触发 fileinput 的 click 事件,打开操作系统的文件选择器
- 选中文件后,触发 fileinput 的 onchange 事件,这里进入到
handleFileChange
的回调 - 在
handleFileChange
的回调中,如果检测到文件长度大于0,触发uploaderFormButton
的 click 事件 - 因为
uploaderFormButton
按钮是一个submit
,所以这个按钮点击会触发对应的form
的提交 - 因为这个
form
使用了 sveltekit 的 progressive enhance,所以结束后不会触发重定向,反而还能自定义回调,避免刷新页面
到这里上传功能基本就做完了,我觉得最重要的还是 progressive enhance 这部分,避免了页面刷新,当然自己实现一个 fetch,然后通过 xhr request 发送也行,但是这样不太符合全栈开发的思想,如果必复用就没必要抽象。
实现文件列表
上面说到已经实现了一个 sidebar,在这个 sidebar 里面放的是文件名列表,但是我想模仿 replit 实现点击每个文件的时候,在右侧的编辑器界面会根据不同文件类型进行渲染,比如说我如果点击打开一个pdf文件,右侧就应该 embed
一个pdf文件,我如果打开一个图片,右侧就应该用 <img>
标签展示这个图片,如果我打开的是可编辑的文件,右侧就应该用我自己封装的 <CodeMirror>
组件展示编辑器。
判断当前打开的文件及类型都好说,就用 svelte 的响应式编程就好,左侧显示哪个文件被打开这块要用到 daisyui 的 menu-active
组件
<li> <a class="menu-active" href="#main.typ"></li>
加上这个类名,就会有选中的效果了。还有一点要注意,我这里是用 url hash 来标识当前打开的文件名的,不是一个内部状态,这样做的好处是方便分享链接
import { page } from "$app/state";
let currentFile = $derived.by(() => { const urlHash = page.url.hash.slice(1); if (files.includes(urlHash)) { return urlHash; } return "main.typ";});
在 svelte 里面可以从 page
里面取到hash,然后通过 rune
绑定到 currentFile
变量上。
总结
实现这个编辑器还遇到了一些其它的小问题,比如说如何用 vite 导入静态文件,在前端如何读取文件内容,后端又怎么读,在切换打开的文件的时候,如何保证编辑器的内容不丢失,如何及时保存到服务器上等等,不过本文主要是讨论UI,所以不在这里多说。
总体来说 daisyui 是一个很优秀的UI组件库,目前作者跟进了最新的 tailwindcss v4
版本,发布了对应的 daisyui@beta
版本,整体有了很大提升。相对来说我对 shadcn
就没有那么有好感了,虽然 shadcn
内置了很多比较复杂但是又常用的组件,但是用起来,升级起来还是没有 daisyui 方便。
如果有的组件或者布局在 daisyui 官网找不到,还可以去以下网站找:
如果还是没有就只能靠搜索引擎或者 AI 了,最后再自己写,做界面是比较重复的事情,自己花时间从头写一个组件不划算。