Article Editor Toolbar
blockmediaMedium and Substack style rich text formatting toolbar and article drafting canvas with typography controls, syntax highlighted code blocks, callouts, and publishing drawer.
Installation
$pnpm dlx uipkge-ng@latest add article-editor-toolbar$npx uipkge-ng@latest add article-editor-toolbar$yarn dlx uipkge-ng@latest add article-editor-toolbar$bunx uipkge-ng@latest add article-editor-toolbarManifest:
https://uipkge.dev/r/angular/article-editor-toolbar.jsonInstalls to:src/app/components/blocks/article-editor-toolbar/Variants
Loading interactive previews…
Loading interactive previews…
Files installed (2)
src/app/components/blocks/article-editor-toolbar/article-editor-toolbar.component.ts54.7 kB
// Medium and Substack style rich text formatting toolbar and article drafting canvas with // typography controls, syntax highlighted code blocks, callouts, and publishing drawer. // Port of the React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, inject, signal } from '@angular/core' import { AlignCenter, AlignLeft, AlignRight, AlertTriangle, ArrowLeft, ArrowUpRight, Bold, Bookmark, BookOpen, Check, CheckCheck, ChevronDown, Cloud, Code, Copy, ExternalLink, Eye, FileCode, Heart, HelpCircle, ImageIcon, Info, Italic, Layers, Lightbulb, Link2, List, ListOrdered, LucideAngularModule, Maximize2, MessageSquare, Minus, MoreHorizontal, PenLine, Plus, Quote, Redo2, RotateCcw, Send, Share2, Sparkles, Strikethrough, Tag, Terminal, Type, Underline, Undo2, Upload, Volume2, X, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export type ArticleTextStyle = 'paragraph' | 'h1' | 'h2' | 'h3' export type ArticleCalloutType = 'tip' | 'warning' | 'info' @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-article-editor-toolbar, [ui-article-editor-toolbar]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, UiInputComponent, UiSeparatorComponent, UiTextareaComponent, ], template: ` <div data-slot="article-editor-toolbar" [class]="rootClass"> <!-- Top Sticky Application Header Bar --> <header class="border-border bg-background/95 sticky top-0 z-40 border-b backdrop-blur-md"> <div class="mx-auto flex h-14 max-w-6xl items-center justify-between gap-2 overflow-x-auto px-4 sm:px-6"> <!-- Left: Publication & Save Status --> <div class="flex items-center gap-3"> <div class="flex items-center gap-2"> <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md"> <lucide-icon [img]="BookOpen" class="size-4" /> </div> <div class="hidden min-w-0 truncate whitespace-nowrap sm:block"> <span class="text-foreground text-sm font-semibold tracking-tight">{{ publicationName }}</span> <span class="text-muted-foreground ml-1.5 text-xs">/ Editor</span> </div> </div> <div ui-separator orientation="vertical" class="hidden h-4 sm:block"></div> <button type="button" class="hover:bg-muted/60 text-muted-foreground hover:text-foreground flex min-h-6 items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors" (click)="triggerManualSave()" title="Click to save now" > @if (!isAutosaving()) { <lucide-icon [img]="Cloud" class="text-success size-3.5" /> } @else { <lucide-icon [img]="RotateCcw" class="text-warning size-3.5 animate-spin" /> } <span class="hidden md:inline">{{ lastSavedText() }}</span> <span class="md:hidden">Saved</span> </button> </div> <!-- Center: Live Story Statistics --> <div class="text-muted-foreground hidden items-center gap-2 text-xs md:flex"> <span ui-badge variant="outline" class="font-normal">{{ wordCount().toLocaleString() }} words</span> <span>·</span> <span>{{ readingTimeMinutes() }} min read</span> </div> <!-- Right: Actions & Publishing Controls --> <div class="flex items-center gap-2"> <div class="hidden items-center gap-0.5 sm:flex"> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Undo action')" title="Undo (⌘Z)" > <lucide-icon [img]="Undo2" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Redo action')" title="Redo (⇧⌘Z)" > <lucide-icon [img]="Redo2" class="size-4" /> </button> </div> <div ui-separator orientation="vertical" class="hidden h-4 sm:block"></div> <button ui-button variant="outline" size="sm" [class]="previewToggleClass" (click)="isPreviewMode.set(!isPreviewMode())" > <lucide-icon [img]="Eye" class="size-3.5" /> <span>{{ isPreviewMode() ? 'Edit Story' : 'Preview Article' }}</span> </button> <button ui-button aria-label="Close publish modal" size="sm" class="h-8 gap-1.5 text-xs font-semibold shadow-xs" (click)="isPublishModalOpen.set(true)" > <lucide-icon [img]="Send" class="size-3.5" /> <span>Publish Story</span> </button> </div> </div> </header> <!-- Floating / Sticky Formatting Toolbar (Only in Editor Mode) --> @if (!isPreviewMode()) { <div class="sticky top-16 z-30 mx-auto mt-4 max-w-3xl px-4 transition-colors duration-200"> <div class="border-border bg-card/95 flex flex-wrap items-center justify-between gap-1 rounded-xl border p-1.5 shadow-sm backdrop-blur-md" > <!-- Group 1: Typography Block Selector --> <div class="flex items-center gap-1"> <div class="relative"> <button ui-button variant="ghost" size="sm" class="text-foreground hover:bg-muted h-8 gap-1 px-2 text-xs font-medium" (click)="isStyleDropdownOpen.set(!isStyleDropdownOpen())" > <lucide-icon [img]="Type" class="text-muted-foreground size-3.5" /> <span class="capitalize">{{ styleLabel() }}</span> <lucide-icon [img]="ChevronDown" class="text-muted-foreground size-3" /> </button> @if (isStyleDropdownOpen()) { <div class="border-border bg-popover absolute top-full left-0 z-50 mt-1.5 w-40 rounded-lg border p-1 shadow-md" > <button type="button" [class]="styleOptionClass('paragraph')" (click)="pickTextStyle('paragraph')"> <span>Paragraph</span> <span class="text-muted-foreground text-xs uppercase">Body</span> </button> <button type="button" [class]="styleOptionClass('h1')" (click)="pickTextStyle('h1')"> <span class="text-sm font-bold">Heading 1</span> <span class="text-muted-foreground text-xs">H1</span> </button> <button type="button" [class]="styleOptionClass('h2')" (click)="pickTextStyle('h2')"> <span class="text-xs font-semibold">Heading 2</span> <span class="text-muted-foreground text-xs">H2</span> </button> <button type="button" [class]="styleOptionClass('h3')" (click)="pickTextStyle('h3')"> <span class="text-xs font-medium">Heading 3</span> <span class="text-muted-foreground text-xs">H3</span> </button> </div> } </div> <div ui-separator orientation="vertical" class="mx-0.5 h-4"></div> <!-- Group 2: Inline Formatting --> <div class="flex items-center gap-0.5"> <button ui-button variant="ghost" size="icon" [class]="inlineToggleClass(isBoldActive(), 'bg-muted text-primary font-bold')" (click)="toggleBold()" title="Bold (⌘B)" > <lucide-icon [img]="Bold" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" [class]="inlineToggleClass(isItalicActive(), 'bg-muted text-primary')" (click)="toggleItalic()" title="Italic (⌘I)" > <lucide-icon [img]="Italic" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" [class]="inlineToggleClass(isUnderlineActive(), 'bg-muted text-primary')" (click)="toggleUnderline()" title="Underline (⌘U)" > <lucide-icon [img]="Underline" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" [class]="inlineToggleClass(isStrikeActive(), 'bg-muted text-primary')" (click)="toggleStrike()" title="Strikethrough (⇧⌘X)" > <lucide-icon [img]="Strikethrough" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" [class]="inlineToggleClass(isInlineCodeActive(), 'bg-muted text-primary font-mono')" (click)="toggleInlineCode()" title="Inline Code (⌘E)" > <lucide-icon [img]="Code" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="isLinkModalOpen.set(true)" title="Insert Link (⌘K)" > <lucide-icon [img]="Link2" class="size-3.5" /> </button> </div> </div> <!-- Group 3: Block Elements & Media --> <div class="flex items-center gap-1"> <div ui-separator orientation="vertical" class="mx-0.5 h-4"></div> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Blockquote formatted')" title="Blockquote (⌘⇧.)" > <lucide-icon [img]="Quote" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="cycleCallout()" title="Toggle Callout Box" > <lucide-icon [img]="Lightbulb" class="text-warning size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Code block activated')" title="Code Block (\`\`\`)" > <lucide-icon [img]="FileCode" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Bullet list toggled')" title="Bullet List (⌘⇧8)" > <lucide-icon [img]="List" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Numbered list toggled')" title="Numbered List (⌘⇧7)" > <lucide-icon [img]="ListOrdered" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Horizontal divider inserted')" title="Divider (---)" > <lucide-icon [img]="Minus" class="size-3.5" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Image upload dialog simulated')" title="Upload Media" > <lucide-icon [img]="ImageIcon" class="size-3.5" /> </button> <div ui-separator orientation="vertical" class="mx-0.5 h-4"></div> <button ui-button variant="ghost" size="sm" class="text-primary hover:bg-primary/10 h-8 gap-1 px-2 text-xs font-medium" (click)="handleAiAssist()" title="AI Writing Assistant" > <lucide-icon [img]="Sparkles" class="size-3.5" /> <span class="hidden sm:inline">AI Assist</span> </button> </div> </div> </div> } <!-- MAIN ARTICLE DRAFTING CANVAS / READER PREVIEW --> <main class="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-12"> @if (isPublishedSuccess()) { <div class="border-success/30 bg-success/10 text-success mb-8 flex items-center justify-between rounded-lg border p-4" > <div class="flex items-center gap-3"> <lucide-icon [img]="CheckCheck" class="text-success size-5" /> <div> <p class="text-sm font-semibold">Story Live on The Engineering Digest</p> <p class="text-muted-foreground text-xs">URL: {{ canonicalUrl() }}</p> </div> </div> <button ui-button size="sm" variant="outline" class="h-8 text-xs" (click)="isPublishedSuccess.set(false)"> Dismiss </button> </div> } <article class="space-y-6"> <div class="flex items-center justify-between"> <span ui-badge variant="secondary" class="gap-1 px-2.5 py-0.5 text-xs font-medium tracking-wide"> <lucide-icon [img]="Tag" class="text-muted-foreground size-3" /> {{ topicTag() }} </span> @if (isPreviewMode()) { <div class="flex items-center gap-1.5"> <button ui-button variant="ghost" size="sm" [class]="readerFontToggleClass" (click)="toggleReaderFontSize()" > <lucide-icon [img]="Type" class="mr-1 size-3" /> {{ readerFontSize() === 'large' ? 'Standard text' : 'Larger text' }} </button> </div> } </div> @if (!isPreviewMode()) { <div class="space-y-2"> <ui-textarea [value]="articleTitle()" (valueChange)="articleTitle.set($event)" [rows]="2" class="text-foreground placeholder:text-muted-foreground/40 w-full resize-none border-0 bg-transparent p-0 text-3xl font-bold tracking-tight focus-visible:ring-0 focus-visible:outline-hidden sm:text-4xl lg:text-5xl" placeholder="Title..." /> </div> } @else { <h1 [class]="previewTitleClass">{{ articleTitle() }}</h1> } @if (!isPreviewMode()) { <div> <ui-textarea [value]="articleSubtitle()" (valueChange)="articleSubtitle.set($event)" [rows]="2" class="text-muted-foreground placeholder:text-muted-foreground/40 w-full resize-none border-0 bg-transparent p-0 text-lg font-normal focus-visible:ring-0 focus-visible:outline-hidden sm:text-xl" placeholder="Add a subtitle..." /> </div> } @else { <p [class]="previewSubtitleClass">{{ articleSubtitle() }}</p> } <!-- Author / Byline Bar --> <div class="flex flex-wrap items-center justify-between gap-4 py-2"> <div class="flex items-center gap-3"> <div class="bg-primary/15 text-primary flex size-10 items-center justify-center rounded-full text-sm font-semibold" > AR </div> <div> <div class="flex items-center gap-2"> <span class="text-foreground text-sm font-semibold">{{ authorName }}</span> <span class="text-muted-foreground text-xs">{{ authorHandle }}</span> @if (isPreviewMode()) { <button ui-button variant="ghost" size="sm" class="text-primary hover:bg-primary/10 h-6 px-2 text-xs" (click)="toggleFollow()" > {{ isFollowing() ? 'Following' : 'Follow' }} </button> } </div> <p class="text-muted-foreground text-xs"> Published in <span class="text-foreground font-medium">{{ publicationName }}</span> · Oct 24, 2024 · {{ readingTimeMinutes() }} min read </p> </div> </div> <div class="flex items-center gap-1"> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-8 gap-1.5 text-xs" (click)="showToast('Audio version playing (6 min narration)')" > <lucide-icon [img]="Volume2" class="size-3.5" /> <span class="hidden sm:inline">Listen</span> </button> <button ui-button variant="ghost" size="icon" [class]="bookmarkClass" (click)="toggleBookmark()" title="Bookmark story" > <lucide-icon [img]="Bookmark" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="showToast('Link copied to clipboard')" title="Share article" > <lucide-icon [img]="Share2" class="size-4" /> </button> </div> </div> <div ui-separator class="my-6"></div> <!-- ARTICLE BODY PROSE --> <div [class]="proseClass"> <p> For nearly a decade, the standard recipe for building a frontend design system was predictable: spin up a private or public npm monorepo, bundle fifty UI components with Rollup or Vite, and force every application team to install <code class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono text-xs" >@acme/design-system</code > as a heavyweight runtime dependency. </p> <!-- Stylized Callout Box --> <div [class]="calloutClass"> <div class="flex items-start gap-3"> <div class="mt-0.5 shrink-0"> @switch (calloutType()) { @case ('tip') { <lucide-icon [img]="Lightbulb" class="text-warning size-5" /> } @case ('warning') { <lucide-icon [img]="AlertTriangle" class="text-destructive size-5" /> } @default { <lucide-icon [img]="Info" class="text-info size-5" /> } } </div> <div class="space-y-1"> <div class="flex items-center justify-between"> <h4 class="text-sm font-semibold tracking-tight">{{ calloutTitle() }}</h4> @if (!isPreviewMode()) { <div class="flex items-center gap-1"> <button type="button" [class]="calloutTabClass('tip')" (click)="setCalloutType('tip')"> Tip </button> <button type="button" [class]="calloutTabClass('warning')" (click)="setCalloutType('warning')"> Warn </button> <button type="button" [class]="calloutTabClass('info')" (click)="setCalloutType('info')"> Info </button> </div> } </div> <p class="text-muted-foreground text-sm leading-normal"> When UI primitives are distributed via semver npm packages, updating a single button variant requires a patch release, a dependency bump, and a potential cascade of peer-dependency conflicts across twenty consuming micro-frontends. </p> </div> </div> </div> <h2 class="text-foreground pt-4 text-2xl font-bold tracking-tight sm:text-3xl"> 1. The "Own Your Code" Revolution </h2> <p> The <a [href]="linkUrl()" target="_blank" rel="noreferrer" class="text-primary hover:text-primary/80 font-medium underline underline-offset-4 transition-colors" > {{ linkText() }} </a> inverts this dynamic completely. Instead of consuming a closed npm black box, developers pull atomic, clean TypeScript source files directly into their repository via CLI tools like <code class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono text-xs">shadcn-vue</code> or <code class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono text-xs">uipkge</code>. </p> <!-- Syntax-Highlighted Code Block --> <div class="border-border bg-muted/40 my-6 overflow-hidden rounded-xl border font-mono text-xs shadow-xs"> <div class="border-border bg-card flex items-center justify-between border-b px-4 py-2"> <div class="flex items-center gap-2"> <button type="button" [class]="codeTabClass('json')" (click)="activeCodeTab.set('json')"> <lucide-icon [img]="FileCode" class="text-warning size-3.5" /> components.json </button> <button type="button" [class]="codeTabClass('bash')" (click)="activeCodeTab.set('bash')"> <lucide-icon [img]="Terminal" class="text-success size-3.5" /> Terminal CLI </button> </div> <div class="flex items-center gap-2"> <span ui-badge variant="outline" class="h-5 px-1.5 font-mono text-xs uppercase"> {{ activeCodeTab() === 'json' ? 'JSON' : 'BASH' }} </span> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-7 gap-1 px-2 text-xs" (click)="copyCodeSnippet()" > @if (isCodeBlockCopied()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span>{{ isCodeBlockCopied() ? 'Copied' : 'Copy' }}</span> </button> </div> </div> <div class="overflow-x-auto p-4 leading-relaxed"> @if (activeCodeTab() === 'json') { <pre class="text-foreground/90"><code><span class="text-muted-foreground">{{ '{' }}</span>{{ ' ' }}<span class="text-primary font-semibold">"$schema"</span>: <span class="text-success">"https://uipkge.dev/schema.json"</span>,{{ ' ' }}<span class="text-primary font-semibold">"style"</span>: <span class="text-success">"new-york"</span>,{{ ' ' }}<span class="text-primary font-semibold">"rsc"</span>: <span class="text-warning">true</span>,{{ ' ' }}<span class="text-primary font-semibold">"aliases"</span>: <span class="text-muted-foreground">{{ '{' }}</span>{{ ' ' }}<span class="text-primary">"components"</span>: <span class="text-success">"@/components/ui"</span>,{{ ' ' }}<span class="text-primary">"utils"</span>: <span class="text-success">"@/lib/utils"</span>,{{ ' ' }}<span class="text-primary">"blocks"</span>: <span class="text-success">"@/components/blocks"</span>{{ ' ' }}<span class="text-muted-foreground">{{ '}' }}</span>{{ ' ' }}<span class="text-muted-foreground">{{ '}' }}</span></code></pre> } @else { <pre class="text-foreground/90" ><code><span class="text-muted-foreground"># Add rich article drafting block to your project</span>{{ ' ' }}<span class="text-primary font-semibold">npx</span> shadcn-vue@latest add https://uipkge.dev/r/vue/article-editor-toolbar.json <span class="text-success">-y</span></code></pre> } </div> </div> <blockquote class="border-primary bg-muted/20 my-8 rounded-r-lg border-l-4 py-3 pr-4 pl-6 italic"> <p class="text-foreground/90 text-base font-medium sm:text-lg"> "The component registry model inverts the dependency tree. Instead of depending on an external maintainer's release cadence, your team owns every line of component markup, adapting it to your product's exact accessibility and branding needs without vendor lock-in." </p> <footer class="text-muted-foreground mt-2 text-xs font-normal not-italic"> — Guillermo Rauch, CEO at Vercel </footer> </blockquote> <h2 class="text-foreground pt-4 text-2xl font-semibold tracking-tight sm:text-3xl"> 2. Core Architectural Advantages </h2> <p> When evaluating the switch from packaged distributions to registry generation, teams consistently report three major velocity unlocks: </p> <ol class="space-y-3 pl-1"> <li class="flex items-start gap-3"> <span class="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold" > 1 </span> <div> <strong class="text-foreground font-semibold">Zero Runtime Overhead:</strong> <span class="text-muted-foreground ml-1"> Unused component variants and dead code branches are automatically pruned during application tree-shaking. </span> </div> </li> <li class="flex items-start gap-3"> <span class="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold" > 2 </span> <div> <strong class="text-foreground font-semibold">Direct Source Control:</strong> <span class="text-muted-foreground ml-1"> Audit, modify, and patch WCAG accessibility tags directly inside your repository without waiting for upstream PR merges. </span> </div> </li> <li class="flex items-start gap-3"> <span class="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold" > 3 </span> <div> <strong class="text-foreground font-semibold">Native OKLCH Token Integration:</strong> <span class="text-muted-foreground ml-1"> Colors and elevation variables adapt directly across modern Tailwind CSS v4 design systems. </span> </div> </li> </ol> <!-- Embedded Media Figure --> <div class="border-border bg-card my-8 overflow-hidden rounded-xl border shadow-xs"> <div class="from-primary/10 via-muted to-primary/5 relative flex h-52 items-center justify-center bg-gradient-to-br sm:h-64" > <div class="space-y-2 p-6 text-center"> <div class="bg-card border-border text-primary inline-flex size-12 items-center justify-center rounded-xl border shadow-xs" > <lucide-icon [img]="Layers" class="size-6" /> </div> <h4 class="text-foreground text-sm font-semibold">Unbundled Registry Architecture Flow</h4> <p class="text-muted-foreground max-w-sm text-xs"> Registry CLI pulls atomic SFC files directly into src/components/ui </p> </div> <div class="absolute top-3 right-3 flex items-center gap-1.5"> <span ui-badge variant="outline" class="bg-card text-xs">Figure 1.0</span> <button ui-button aria-label="Expand media view" variant="ghost" size="icon" class="bg-card hover:bg-muted size-7 transition-[colors,transform] duration-150 active:scale-95" (click)="showToast('Expanded media view')" > <lucide-icon [img]="Maximize2" class="size-3.5" /> </button> </div> </div> <div class="border-border bg-card border-t p-3"> <p class="text-muted-foreground text-center text-xs italic"> Figure 1: Monolithic npm package distribution vs. direct registry source ownership. </p> </div> </div> <div class="text-muted-foreground my-10 flex items-center justify-center gap-2"> <span class="bg-border size-1 rounded-full"></span> <span class="bg-muted-foreground/40 size-1.5 rounded-full"></span> <span class="bg-border size-1 rounded-full"></span> </div> <p> The shift from monolithic npm packages to unbundled UI registries is not just a tooling trend; it represents a fundamental re-alignment of software ownership, empowering engineering teams to build resilient interfaces that evolve with their product. </p> </div> </article> @if (isPreviewMode()) { <div class="border-border bg-card/95 sticky bottom-6 z-30 mx-auto mt-12 max-w-lg rounded-full border p-2 shadow-lg backdrop-blur-md" > <div class="flex items-center justify-between px-3"> <div class="flex items-center gap-4"> <button type="button" class="text-foreground hover:text-primary flex min-h-6 items-center gap-1.5 text-xs font-semibold transition-colors" (click)="clap()" > <lucide-icon [img]="Heart" class="fill-destructive/20 text-destructive size-4" /> <span>{{ clapCount() }}</span> </button> <button type="button" class="text-muted-foreground hover:text-foreground flex min-h-6 items-center gap-1.5 text-xs transition-colors" (click)="showToast('Responses panel opened (24 responses)')" > <lucide-icon [img]="MessageSquare" class="size-4" /> <span>24</span> </button> </div> <div class="flex items-center gap-2"> <button ui-button size="sm" variant="ghost" class="text-muted-foreground hover:text-foreground h-7 text-xs" (click)="toggleBookmark()" > <lucide-icon [img]="Bookmark" class="mr-1 size-3.5" /> <span>Bookmark</span> </button> <button ui-button aria-label="Close publish modal" size="sm" class="h-7 text-xs font-semibold" (click)="isPublishModalOpen.set(true)" > Publish </button> </div> </div> </div> } </main> <!-- PUBLISH SETTINGS MODAL / DIALOG --> @if (isPublishModalOpen()) { <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-xs"> <div ui-card class="border-border bg-card animate-in fade-in-0 zoom-in-95 w-full max-w-xl shadow-sm duration-200" > <div ui-card-header class="flex flex-row items-center justify-between pb-4"> <div> <h3 ui-card-title class="text-lg font-semibold">Publish Story to Publication</h3> <p ui-card-description class="text-xs"> Configure publication metadata, topic tags, and newsletter distribution. </p> </div> <button ui-button aria-label="Close publish modal" variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" (click)="isPublishModalOpen.set(false)" > <lucide-icon [img]="X" class="size-4" /> </button> </div> <div ui-card-content class="space-y-4 pt-0 text-sm"> <div class="border-border bg-muted/30 rounded-lg border p-3"> <h4 class="text-foreground line-clamp-1 text-sm font-semibold">{{ articleTitle() }}</h4> <p class="text-muted-foreground mt-0.5 line-clamp-1 text-xs">{{ articleSubtitle() }}</p> <div class="text-muted-foreground mt-2 flex items-center gap-2 text-xs"> <span>Author: {{ authorName }}</span> <span>·</span> <span>{{ wordCount() }} words</span> <span>·</span> <span>{{ readingTimeMinutes() }} min read</span> </div> </div> <div class="space-y-1.5"> <label class="text-foreground text-xs font-medium">Topic Tags (up to 5)</label> <div class="border-input bg-background flex flex-wrap gap-1.5 rounded-lg border p-2"> @for (tag of publishTags(); track tag) { <span ui-badge variant="secondary" class="gap-1 pr-1 text-xs"> {{ tag }} <button [attr.aria-label]="'Remove tag ' + tag" type="button" class="text-muted-foreground hover:text-foreground" (click)="handleRemoveTag(tag)" > <lucide-icon [img]="X" class="size-3" /> </button> </span> } <input [value]="newTagInput()" (input)="newTagInput.set($any($event.target).value)" type="text" class="placeholder:text-muted-foreground min-w-[100px] flex-1 border-0 bg-transparent p-0 text-xs focus:ring-0 focus:outline-hidden" placeholder="Add a tag..." (keydown.enter)="handleAddTagFromKeyboard($event)" /> </div> </div> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <label class="text-foreground text-xs font-medium">SEO Meta Description</label> <span class="text-muted-foreground text-xs">{{ seoDescription().length }}/160</span> </div> <ui-textarea [value]="seoDescription()" (valueChange)="seoDescription.set($event)" [rows]="2" class="resize-none text-xs" placeholder="Brief summary for search engines and social cards..." /> </div> <div class="space-y-1.5"> <label class="text-foreground text-xs font-medium">Canonical URL</label> <ui-input [value]="canonicalUrl()" (valueChange)="canonicalUrl.set($event)" class="h-8 font-mono text-xs" /> </div> <div class="border-border bg-muted/20 flex items-center justify-between rounded-lg border p-3"> <div class="space-y-0.5"> <p class="text-foreground text-xs font-semibold">Broadcast to Subscribers</p> <p class="text-muted-foreground text-xs"> Send as an instant newsletter email to 14,200 active readers. </p> </div> <input [checked]="sendNewsletter()" (change)="sendNewsletter.set($any($event.target).checked)" type="checkbox" class="border-border accent-primary size-4 cursor-pointer rounded" /> </div> <div class="flex items-center gap-4 text-xs"> <label class="flex cursor-pointer items-center gap-2"> <input [checked]="publishSchedule() === 'now'" (change)="publishSchedule.set('now')" type="radio" value="now" name="schedule" class="accent-primary cursor-pointer" /> <span class="text-foreground font-medium">Publish Now</span> </label> <label class="flex cursor-pointer items-center gap-2"> <input [checked]="publishSchedule() === 'schedule'" (change)="publishSchedule.set('schedule')" type="radio" value="schedule" name="schedule" class="accent-primary cursor-pointer" /> <span class="text-muted-foreground">Schedule for later</span> </label> </div> </div> <div ui-card-footer class="border-border flex items-center justify-end gap-2 border-t pt-4"> <button ui-button aria-label="Close publish modal" variant="outline" size="sm" class="text-xs" (click)="isPublishModalOpen.set(false)" > Cancel </button> <button ui-button size="sm" class="gap-1.5 text-xs font-semibold shadow-xs" (click)="handlePublish()"> <lucide-icon [img]="Send" class="size-3.5" /> <span>{{ publishSchedule() === 'now' ? 'Confirm & Publish Now' : 'Schedule Story' }}</span> </button> </div> </div> </div> } <!-- INSERT LINK MODAL --> @if (isLinkModalOpen()) { <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-xs"> <div ui-card class="border-border bg-card w-full max-w-sm shadow-xl"> <div ui-card-header class="pb-3"> <h3 ui-card-title class="text-sm font-semibold">Insert Hyperlink</h3> <p ui-card-description class="text-xs">Add an external reference link</p> </div> <div ui-card-content class="space-y-3 pt-0"> <div class="space-y-1"> <label class="text-muted-foreground text-xs">Link Text</label> <ui-input [value]="linkText()" (valueChange)="linkText.set($event)" class="h-8 text-xs" /> </div> <div class="space-y-1"> <label class="text-muted-foreground text-xs">Destination URL</label> <ui-input [value]="linkUrl()" (valueChange)="linkUrl.set($event)" class="h-8 font-mono text-xs" placeholder="https://" /> </div> </div> <div ui-card-footer class="border-border flex items-center justify-end gap-2 border-t pt-3"> <button ui-button variant="ghost" size="sm" class="h-8 text-xs" (click)="isLinkModalOpen.set(false)"> Cancel </button> <button ui-button size="sm" class="h-8 text-xs" (click)="isLinkModalOpen.set(false); showToast('Link inserted successfully!')" > Apply Link </button> </div> </div> </div> } <!-- INTERACTIVE TOAST NOTIFICATION --> @if (toastMessage()) { <div class="border-border bg-popover text-popover-foreground animate-in fade-in slide-in-from-bottom-2 fixed right-5 bottom-5 z-50 flex items-center gap-2 rounded-lg border px-3.5 py-2.5 text-xs font-medium shadow-lg duration-150" > <lucide-icon [img]="Check" class="text-primary size-3.5" /> <span>{{ toastMessage() }}</span> </div> } </div> `, }) export class UiArticleEditorToolbarComponent { protected readonly AlignCenter = AlignCenter protected readonly AlignLeft = AlignLeft protected readonly AlignRight = AlignRight protected readonly AlertTriangle = AlertTriangle protected readonly ArrowLeft = ArrowLeft protected readonly ArrowUpRight = ArrowUpRight protected readonly Bold = Bold protected readonly Bookmark = Bookmark protected readonly BookOpen = BookOpen protected readonly Check = Check protected readonly CheckCheck = CheckCheck protected readonly ChevronDown = ChevronDown protected readonly Cloud = Cloud protected readonly Code = Code protected readonly Copy = Copy protected readonly ExternalLink = ExternalLink protected readonly Eye = Eye protected readonly FileCode = FileCode protected readonly Heart = Heart protected readonly HelpCircle = HelpCircle protected readonly ImageIcon = ImageIcon protected readonly Info = Info protected readonly Italic = Italic protected readonly Layers = Layers protected readonly Lightbulb = Lightbulb protected readonly Link2 = Link2 protected readonly List = List protected readonly ListOrdered = ListOrdered protected readonly Maximize2 = Maximize2 protected readonly MessageSquare = MessageSquare protected readonly Minus = Minus protected readonly MoreHorizontal = MoreHorizontal protected readonly PenLine = PenLine protected readonly Plus = Plus protected readonly Quote = Quote protected readonly Redo2 = Redo2 protected readonly RotateCcw = RotateCcw protected readonly Send = Send protected readonly Share2 = Share2 protected readonly Sparkles = Sparkles protected readonly Strikethrough = Strikethrough protected readonly Tag = Tag protected readonly Terminal = Terminal protected readonly Type = Type protected readonly Underline = Underline protected readonly Undo2 = Undo2 protected readonly Upload = Upload protected readonly Volume2 = Volume2 protected readonly X = X @Input('class') className?: string // View Mode readonly isPreviewMode = signal(false) // Story Content State readonly articleTitle = signal('The Death of the Monolithic Component Package') readonly articleSubtitle = signal('Why the unbundled registry architecture is replacing npm packages for UI systems.') readonly topicTag = signal('Architecture & UI') readonly authorName = 'Alex Rivera' readonly authorHandle = '@arivera_eng' readonly publicationName = 'The Engineering Digest' readonly lastSavedText = signal('Draft Saved 2m ago') readonly isAutosaving = signal(false) // Reader Engagement State readonly clapCount = signal(342) readonly isBookmarked = signal(false) readonly isFollowing = signal(false) readonly readerFontSize = signal<'normal' | 'large'>('normal') // Toolbar Formatter State readonly activeTextStyle = signal<ArticleTextStyle>('paragraph') readonly isStyleDropdownOpen = signal(false) readonly isBoldActive = signal(false) readonly isItalicActive = signal(false) readonly isUnderlineActive = signal(false) readonly isStrikeActive = signal(false) readonly isInlineCodeActive = signal(false) // Active Block Elements in Canvas readonly calloutType = signal<ArticleCalloutType>('tip') readonly isCodeBlockCopied = signal(false) readonly activeCodeTab = signal<'json' | 'bash'>('json') // Modals / Drawers readonly isPublishModalOpen = signal(false) readonly isLinkModalOpen = signal(false) readonly isPublishedSuccess = signal(false) readonly linkUrl = signal('https://uipkge.dev') readonly linkText = signal('unbundled registry architecture') // Publish Settings State readonly publishTags = signal(['Web Development', 'Design Systems', 'Vue', 'React', 'Frontend']) readonly newTagInput = signal('') readonly canonicalUrl = signal('https://theengineeringdigest.io/p/unbundled-ui-registries') readonly seoDescription = signal( 'A deep dive into why unbundled UI component registries like shadcn and uipkge are displacing monolithic npm component libraries across engineering teams.', ) readonly sendNewsletter = signal(true) readonly publishSchedule = signal<'now' | 'schedule'>('now') // Toast Feedback readonly toastMessage = signal<string | null>(null) private toastTimer: ReturnType<typeof setTimeout> | undefined constructor() { inject(DestroyRef).onDestroy(() => clearTimeout(this.toastTimer)) } get rootClass(): string { return cn('bg-background text-foreground min-h-screen', this.className) } wordCount(): number { const fullText = `${this.articleTitle()} ${this.articleSubtitle()} For nearly a decade the standard recipe for building a frontend design system was predictable spin up a private or public npm monorepo bundle fifty UI components with Rollup or Vite and force every application team to install as a heavyweight runtime dependency The component registry model inverts this entire dynamic Instead of consuming a closed npm black box developers pull atomic clean TypeScript source files directly into their repository Zero Runtime Overhead Direct Accessibility Ownership Native Token Sync` return fullText.trim().split(/\s+/).filter(Boolean).length + 1380 } readingTimeMinutes(): number { return Math.max(1, Math.ceil(this.wordCount() / 220)) } get previewToggleClass(): string { return cn( 'h-8 gap-1.5 text-xs font-medium', this.isPreviewMode() && 'bg-secondary text-secondary-foreground border-primary/40', ) } get bookmarkClass(): string { return cn('text-muted-foreground hover:text-foreground size-8', this.isBookmarked() && 'text-primary') } get readerFontToggleClass(): string { return cn('text-muted-foreground h-7 text-xs', this.readerFontSize() === 'large' && 'bg-muted text-foreground') } get previewTitleClass(): string { return cn( 'text-foreground text-3xl leading-tight font-bold tracking-tight sm:text-4xl lg:text-5xl', this.readerFontSize() === 'large' && 'text-4xl sm:text-5xl lg:text-6xl', ) } get previewSubtitleClass(): string { return cn( 'text-muted-foreground text-lg leading-relaxed font-normal sm:text-xl', this.readerFontSize() === 'large' && 'text-xl sm:text-2xl', ) } get proseClass(): string { return cn( 'text-foreground space-y-6 leading-relaxed', this.readerFontSize() === 'large' ? 'text-lg sm:text-xl' : 'text-base sm:text-lg', ) } get calloutClass(): string { return cn( 'my-8 rounded-xl border p-4.5 transition-colors sm:p-5', this.calloutType() === 'tip' ? 'text-foreground border-warning/30 bg-warning/10' : this.calloutType() === 'warning' ? 'text-foreground border-destructive/30 bg-destructive/10' : 'text-foreground border-info/30 bg-info/10', ) } calloutTitle(): string { const t = this.calloutType() return t === 'tip' ? 'Key Architectural Takeaway' : t === 'warning' ? 'Dependency Fragility Warning' : 'Registry Distribution Note' } calloutTabClass(type: ArticleCalloutType): string { return cn( 'text-muted-foreground hover:text-foreground min-h-6 rounded px-1.5 py-0.5 text-xs', this.calloutType() === type && 'text-foreground font-semibold', ) } codeTabClass(tab: 'json' | 'bash'): string { return cn( 'flex min-h-6 items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-colors', this.activeCodeTab() === tab ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground', ) } styleLabel(): string { const s = this.activeTextStyle() return s === 'paragraph' ? 'Normal Text' : s.toUpperCase() } styleOptionClass(style: ArticleTextStyle): string { return cn( 'hover:bg-muted flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-xs transition-colors', this.activeTextStyle() === style ? 'text-primary bg-muted/60 font-semibold' : 'text-popover-foreground', ) } inlineToggleClass(active: boolean, activeClass: string): string { return cn('size-8', active ? activeClass : 'text-muted-foreground hover:text-foreground') } showToast(msg: string): void { clearTimeout(this.toastTimer) this.toastMessage.set(msg) this.toastTimer = setTimeout(() => this.toastMessage.set(null), 3000) } triggerManualSave(): void { this.isAutosaving.set(true) this.lastSavedText.set('Saving changes...') setTimeout(() => { this.isAutosaving.set(false) this.lastSavedText.set('Draft Saved just now') this.showToast('All draft changes saved to cloud.') }, 600) } handleAddTag(): void { const trimmed = this.newTagInput().trim() if (trimmed && !this.publishTags().includes(trimmed)) { this.publishTags.update((prev) => [...prev, trimmed]) this.newTagInput.set('') } } handleAddTagFromKeyboard(event: Event): void { event.preventDefault() this.handleAddTag() } handleRemoveTag(tagToRemove: string): void { this.publishTags.update((prev) => prev.filter((t) => t !== tagToRemove)) } copyCodeSnippet(): void { this.isCodeBlockCopied.set(true) this.showToast('Code snippet copied to clipboard!') setTimeout(() => this.isCodeBlockCopied.set(false), 2000) } handlePublish(): void { this.isPublishModalOpen.set(false) this.isPublishedSuccess.set(true) this.showToast('🎉 Story published successfully to The Engineering Digest!') } setCalloutType(type: ArticleCalloutType): void { this.calloutType.set(type) } insertCallout(type: ArticleCalloutType): void { this.calloutType.set(type) this.showToast(`Applied ${type.toUpperCase()} callout formatting.`) } cycleCallout(): void { const current = this.calloutType() this.insertCallout(current === 'tip' ? 'warning' : current === 'warning' ? 'info' : 'tip') } pickTextStyle(style: ArticleTextStyle): void { this.activeTextStyle.set(style) this.isStyleDropdownOpen.set(false) } toggleBold(): void { const next = !this.isBoldActive() this.isBoldActive.set(next) this.showToast(next ? 'Bold applied' : 'Bold removed') } toggleItalic(): void { const next = !this.isItalicActive() this.isItalicActive.set(next) this.showToast(next ? 'Italic applied' : 'Italic removed') } toggleUnderline(): void { const next = !this.isUnderlineActive() this.isUnderlineActive.set(next) this.showToast(next ? 'Underline applied' : 'Underline removed') } toggleStrike(): void { this.isStrikeActive.update((v) => !v) this.showToast('Strikethrough toggled') } toggleInlineCode(): void { this.isInlineCodeActive.update((v) => !v) this.showToast('Inline code toggled') } toggleBookmark(): void { const next = !this.isBookmarked() this.isBookmarked.set(next) this.showToast(next ? 'Saved to bookmarks' : 'Removed from bookmarks') } toggleFollow(): void { const next = !this.isFollowing() this.isFollowing.set(next) this.showToast(next ? 'Following Alex Rivera' : 'Unfollowed') } toggleReaderFontSize(): void { this.readerFontSize.update((v) => (v === 'normal' ? 'large' : 'normal')) } clap(): void { this.clapCount.update((v) => v + 1) this.showToast(`Clapped! (${this.clapCount()} claps total)`) } handleAiAssist(): void { this.showToast('✨ AI polished paragraph for conciseness and punchy cadence.') } }src/app/components/blocks/article-editor-toolbar/index.ts0.1 kB
export { UiArticleEditorToolbarComponent, type ArticleCalloutType, type ArticleTextStyle, } from './article-editor-toolbar.component'
Raw manifest:https://uipkge.dev/r/angular/article-editor-toolbar.json