Coverage for src / lexigram / ui / atoms / editors.py: 18%

51 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui.core.base import Component, el 

6 

7 

8class RichEditor(Component): 

9 """ 

10 WYSIWYG editor using Trix. 

11 Requires Trix JS/CSS to be loaded in the page. 

12 """ 

13 

14 def __init__( 

15 self, 

16 name: str, 

17 value: str | None = None, 

18 label: str | None = None, 

19 placeholder: str | None = None, 

20 error: str | None = None, 

21 disabled: bool = False, 

22 required: bool = False, 

23 min_height: int = 300, 

24 toolbar: str | None = None, 

25 **props, 

26 ) -> None: 

27 super().__init__( 

28 name=name, 

29 value=value, 

30 label=label, 

31 placeholder=placeholder, 

32 error=error, 

33 disabled=disabled, 

34 required=required, 

35 min_height=min_height, 

36 toolbar=toolbar, 

37 **props, 

38 ) 

39 self.name = name 

40 self.value = value or "" 

41 self.label = label 

42 self.placeholder = placeholder 

43 self.error = error 

44 self.disabled = disabled 

45 self.required = required 

46 self.min_height = min_height 

47 self.toolbar = toolbar 

48 

49 def render(self) -> Any: 

50 # Trix uses a hidden input and a trix-editor element 

51 input_el = el( 

52 "input", 

53 type="hidden", 

54 name=self.name, 

55 id=f"{self.name}_input", 

56 value=self.value, 

57 ) 

58 

59 editor_classes = ( 

60 "block w-full rounded-lg border-0 py-2 px-3 shadow-sm ring-1 ring-inset " 

61 "ring-[var(--input)] bg-background " 

62 "text-foreground prose dark:prose-invert max-w-none " 

63 "focus:ring-2 focus:ring-ring" 

64 ) 

65 

66 editor_kwargs: dict[str, Any] = { 

67 "input": f"{self.name}_input", 

68 "placeholder": self.placeholder, 

69 "class_": editor_classes, 

70 "disabled": "" if self.disabled else None, 

71 "style": f"min-height: {self.min_height}px", 

72 } 

73 if self.toolbar is not None: 

74 editor_kwargs["data-toolbar"] = self.toolbar 

75 editor_el = el("trix-editor", **editor_kwargs) 

76 

77 content = el("div", input_el, editor_el) 

78 

79 if self.label: 

80 return el( 

81 "div", 

82 el( 

83 "label", 

84 self.label, 

85 for_=f"{self.name}_input", 

86 class_="block text-sm font-medium text-foreground opacity-70 mb-1", 

87 ), 

88 content, 

89 ( 

90 el( 

91 "p", 

92 self.error, 

93 id=f"{self.name}-error", 

94 class_="mt-2 text-sm text-destructive", 

95 ) 

96 if self.error 

97 else "" 

98 ), 

99 class_="mb-4", 

100 ) 

101 

102 return content 

103 

104 

105class MarkdownEditor(Component): 

106 """ 

107 Markdown editor with optional preview toggle. 

108 Uses Alpine.js for client-side preview mode switching. 

109 """ 

110 

111 def __init__( 

112 self, 

113 name: str, 

114 value: str | None = None, 

115 label: str | None = None, 

116 placeholder: str | None = None, 

117 error: str | None = None, 

118 rows: int = 10, 

119 disabled: bool = False, 

120 preview: bool = True, 

121 min_height: int = 300, 

122 **props, 

123 ) -> None: 

124 super().__init__( 

125 name=name, 

126 value=value, 

127 label=label, 

128 placeholder=placeholder, 

129 error=error, 

130 rows=rows, 

131 disabled=disabled, 

132 preview=preview, 

133 min_height=min_height, 

134 **props, 

135 ) 

136 self.name = name 

137 self.value = value or "" 

138 self.label = label 

139 self.placeholder = placeholder 

140 self.error = error 

141 self.rows = rows 

142 self.disabled = disabled 

143 self.preview = preview 

144 self.min_height = min_height 

145 

146 def render(self) -> Any: 

147 textarea_classes = ( 

148 "block w-full rounded-lg border-0 py-2 px-3 shadow-sm ring-1 " 

149 "ring-inset focus:ring-2 focus:ring-inset sm:text-sm transition-all " 

150 "duration-200 bg-background text-foreground " 

151 "placeholder:text-muted-foreground font-mono " 

152 f"{'ring-red-300 focus:ring-red-500' if self.error else 'ring-[var(--input)] focus:ring-ring'}" 

153 f"{' opacity-50' if self.disabled else ''}" 

154 ) 

155 

156 escaped_value = self.value.replace("\\", "\\\\").replace("'", "\\'") 

157 

158 x_data = ( 

159 "{ mode: 'edit', get rendered() { " 

160 "const el = document.createElement('div'); " 

161 "el.textContent = this.$refs.textarea.value || ''; " 

162 "return el.innerHTML.replace(/\\n/g, '<br>'); } }" 

163 ) 

164 

165 textarea_el = el( 

166 "textarea", 

167 self.value, 

168 name=self.name, 

169 id=self.name, 

170 rows=self.rows, 

171 placeholder=self.placeholder, 

172 disabled=self.disabled, 

173 **{ 

174 "x-ref": "textarea", 

175 "x-model": "mode === 'edit' ? undefined : undefined", 

176 }, 

177 class_=textarea_classes, 

178 style=f"min-height: {self.min_height}px", 

179 ) 

180 

181 preview_el = el( 

182 "div", 

183 **{"x-show": "mode === 'preview'", "x-html": "rendered"}, 

184 class_=( 

185 "prose dark:prose-invert max-w-none p-3 rounded-lg border " 

186 "border-border bg-background " 

187 f"min-h-[{self.min_height}px]" 

188 ), 

189 ) 

190 

191 toggle_btn = el( 

192 "button", 

193 type="button", 

194 **{ 

195 "@click": "mode = mode === 'edit' ? 'preview' : 'edit'", 

196 "x-text": "mode === 'edit' ? 'Preview' : 'Edit'", 

197 }, 

198 class_=( 

199 "text-xs font-medium px-2.5 py-1 rounded-md transition-colors duration-200 " 

200 "text-muted-foreground " 

201 "hover:text-foreground " 

202 "hover:bg-accent" 

203 ), 

204 ) 

205 

206 editor_wrap = el( 

207 "div", 

208 textarea_el, 

209 preview_el, 

210 **{"x-show": "mode === 'edit'", "x-cloak": ""}, 

211 ) 

212 

213 header = ( 

214 el( 

215 "div", 

216 toggle_btn, 

217 class_="flex items-center justify-end mb-1", 

218 ) 

219 if self.preview 

220 else "" 

221 ) 

222 

223 content = el( 

224 "div", 

225 header, 

226 editor_wrap, 

227 **{"x-data": x_data}, 

228 ) 

229 

230 if self.label: 

231 return el( 

232 "div", 

233 el( 

234 "label", 

235 self.label, 

236 for_=self.name, 

237 class_="block text-sm font-medium text-foreground opacity-70 mb-1", 

238 ), 

239 content, 

240 ( 

241 el( 

242 "p", 

243 self.error, 

244 id=f"{self.name}-error", 

245 class_="mt-2 text-sm text-destructive", 

246 ) 

247 if self.error 

248 else "" 

249 ), 

250 class_="mb-4", 

251 ) 

252 

253 return content