@@ -8,6 +8,19 @@ import { Crepe } from '@milkdown/crepe'
88// any literal color.
99import '@milkdown/crepe/theme/common/style.css'
1010import { $remark } from '@milkdown/utils'
11+ import { tooltipFactory , TooltipProvider } from '@milkdown/kit/plugin/tooltip'
12+ import { commandsCtx } from '@milkdown/kit/core'
13+ import {
14+ isMarkSelectedCommand ,
15+ toggleEmphasisCommand ,
16+ toggleInlineCodeCommand ,
17+ toggleStrongCommand ,
18+ strongSchema ,
19+ emphasisSchema ,
20+ inlineCodeSchema ,
21+ } from '@milkdown/kit/preset/commonmark'
22+ import { toggleStrikethroughCommand , strikethroughSchema } from '@milkdown/kit/preset/gfm'
23+ import type { EditorView } from '@milkdown/kit/prose/view'
1124
1225// The Milkdown machinery behind shared/MilkdownEditor.tsx -- reachable
1326// only through that component's dynamic import (goal 0244 S3), so the
@@ -30,6 +43,10 @@ export const NOTE_FEATURES = {
3043 [ Crepe . Feature . Latex ] : false ,
3144 [ Crepe . Feature . BlockEdit ] : false ,
3245 [ Crepe . Feature . CodeMirror ] : false ,
46+ // Crepe's own selection toolbar is replaced by Mill's floating one
47+ // (goal 0253, attachSelectionToolbar below) -- Crepe mounts its
48+ // toolbar inside the editor node, where a canvas note clips it.
49+ [ Crepe . Feature . Toolbar ] : false ,
3350}
3451
3552// Disables CommonMark's INDENTED code block construct (a bare line
@@ -53,3 +70,122 @@ export const disableIndentedCodeBlock = $remark('disableIndentedCodeBlock', () =
5370 extensions . push ( { disable : { null : [ 'codeIndented' ] } } )
5471 }
5572} )
73+
74+ // --- The selection toolbar (goal 0253) ---
75+ //
76+ // Crepe's own toolbar feature is OFF (NOTE_FEATURES below): it mounts
77+ // its content through TooltipProvider's default parent -- inside the
78+ // editor's own node -- where a canvas note clips it, the board's zoom
79+ // transform shrinks it, and floating-ui misplaces it (absolute
80+ // positioning inside a transformed ancestor). Its config exposes no
81+ // mount point, so Mill registers its OWN selection toolbar through the
82+ // same kit primitives, with the provider's documented `root:
83+ // document.body` -- body is untransformed, so the toolbar floats at UI
84+ // scale beside the selection regardless of board zoom, and nothing
85+ // ever clips it. The toolbar CONTENT stays framework-owned: the host
86+ // (MilkdownEditor.tsx) portals Mill's React buttons into `contentEl`;
87+ // this module only owns registration, positioning, active-mark state,
88+ // and the command calls.
89+
90+ export type SelectionToolbarAction = 'bold' | 'italic' | 'strikethrough' | 'code'
91+
92+ export interface SelectionToolbarState {
93+ bold : boolean
94+ italic : boolean
95+ strikethrough : boolean
96+ code : boolean
97+ }
98+
99+ export interface SelectionToolbarHandle {
100+ // The floating element Mill's React toolbar portals into. Appended
101+ // to document.body by the provider on first update; removed on
102+ // destroy.
103+ contentEl : HTMLElement
104+ run : ( action : SelectionToolbarAction ) => void
105+ onState : ( cb : ( state : SelectionToolbarState ) => void ) => void
106+ destroy : ( ) => void
107+ }
108+
109+ const millSelectionToolbar = tooltipFactory ( 'MILL_SELECTION_TOOLBAR' )
110+
111+ // attachSelectionToolbar wires the tooltip plugin onto a Crepe editor.
112+ // Must run BEFORE crepe.create() (plugins register at create); the
113+ // returned handle stays valid for the editor's whole life.
114+ export function attachSelectionToolbar ( crepe : Crepe , className : string ) : SelectionToolbarHandle {
115+ const contentEl = document . createElement ( 'div' )
116+ contentEl . className = className
117+ contentEl . dataset . testid = 'milkdown-selection-toolbar'
118+ // The marker outside-press commit listeners key on (goal 0253): the
119+ // toolbar floats at body level, OUTSIDE any editor wrapper, yet a
120+ // press on it is part of the edit session -- hosts exclude
121+ // [data-milkdown-selection-toolbar] from their own commit-on-
122+ // outside-press logic (AtlasStickyNode's document listener).
123+ contentEl . setAttribute ( 'data-milkdown-selection-toolbar' , '' )
124+ let stateCb : ( ( state : SelectionToolbarState ) => void ) | null = null
125+ let provider : TooltipProvider | null = null
126+
127+ const readState = ( ) : SelectionToolbarState =>
128+ crepe . editor . action ( ( ctx ) => {
129+ const commands = ctx . get ( commandsCtx )
130+ return {
131+ bold : commands . call ( isMarkSelectedCommand . key , strongSchema . type ( ctx ) ) ,
132+ italic : commands . call ( isMarkSelectedCommand . key , emphasisSchema . type ( ctx ) ) ,
133+ strikethrough : commands . call ( isMarkSelectedCommand . key , strikethroughSchema . type ( ctx ) ) ,
134+ code : commands . call ( isMarkSelectedCommand . key , inlineCodeSchema . type ( ctx ) ) ,
135+ }
136+ } )
137+
138+ class MillToolbarView {
139+ constructor ( view : EditorView ) {
140+ provider = new TooltipProvider ( {
141+ content : contentEl ,
142+ root : document . body ,
143+ debounce : 20 ,
144+ offset : 8 ,
145+ } )
146+ provider . onShow = ( ) => stateCb ?.( readState ( ) )
147+ this . update ( view )
148+ }
149+
150+ update = ( view : EditorView , prevState ?: Parameters < TooltipProvider [ 'update' ] > [ 1 ] ) => {
151+ provider ?. update ( view , prevState )
152+ if ( contentEl . dataset . show === 'true' ) stateCb ?.( readState ( ) )
153+ }
154+
155+ destroy = ( ) => {
156+ provider ?. destroy ( )
157+ contentEl . remove ( )
158+ }
159+ }
160+
161+ crepe . editor . use ( millSelectionToolbar )
162+ crepe . editor . config ( ( ctx ) => {
163+ ctx . set ( millSelectionToolbar . key , {
164+ view : ( view : EditorView ) => new MillToolbarView ( view ) ,
165+ } )
166+ } )
167+
168+ const COMMANDS = {
169+ bold : toggleStrongCommand ,
170+ italic : toggleEmphasisCommand ,
171+ strikethrough : toggleStrikethroughCommand ,
172+ code : toggleInlineCodeCommand ,
173+ } as const
174+
175+ return {
176+ contentEl,
177+ run : ( action ) => {
178+ crepe . editor . action ( ( ctx ) => {
179+ ctx . get ( commandsCtx ) . call ( COMMANDS [ action ] . key )
180+ } )
181+ stateCb ?.( readState ( ) )
182+ } ,
183+ onState : ( cb ) => {
184+ stateCb = cb
185+ } ,
186+ destroy : ( ) => {
187+ provider ?. destroy ( )
188+ contentEl . remove ( )
189+ } ,
190+ }
191+ }
0 commit comments