Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions lib/const/placed_media_geometry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ abstract final class PlacedMediaGeometry {
final fontSizeInPixels =
textFontSizeInWorld(text) * _referencePixelsPerWorldUnit;

// The text field sits after the 6 px tag, 2 px gap, and the card's 5 px
// horizontal padding on each side. Material's borderless field contributes
// its intrinsic vertical chrome and retains a 48 px minimum height.
// This describes the pre-Markdown TextField card shipped before canonical
// coordinates, including its intrinsic vertical chrome and 48 px minimum.
final painter = TextPainter(
text: TextSpan(
text: text.text.isEmpty ? 'Write here...' : text.text,
Expand Down
88 changes: 88 additions & 0 deletions lib/widgets/draggable_widgets/text/formatted_text_view.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart';

class FormattedTextView extends StatelessWidget {
const FormattedTextView({
super.key,
required this.text,
required this.style,
required this.hintText,
});

final String text;
final TextStyle style;
final String hintText;

@override
Widget build(BuildContext context) {
if (text.isEmpty) {
return Text(
hintText,
style: style.copyWith(color: Colors.grey),
);
}
final fontSize = style.fontSize ?? 14;
final lines = parseMarkup(text);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (final line in lines) _buildLine(line, fontSize),
],
);
}

Widget _buildLine(MarkupLine line, double fontSize) {
if (line.plainText.isEmpty) {
return SizedBox(
height: fontSize * (style.height ?? 1.2),
);
}
final contentStyle = line.kind == MarkupLineKind.heading
? style.copyWith(
fontWeight: FontWeight.w600,
fontSize: fontSize * markupHeadingScale,
)
: style;
final content = Text.rich(
TextSpan(
style: contentStyle,
children: [
for (final inline in line.inlines)
if (!inline.isMarker)
TextSpan(
text: inline.raw,
style: contentStyle.copyWith(
fontWeight:
inline.bold ? FontWeight.w700 : contentStyle.fontWeight,
fontStyle:
inline.italic ? FontStyle.italic : contentStyle.fontStyle,
),
),
],
),
);
if (line.kind == MarkupLineKind.paragraph ||
line.kind == MarkupLineKind.heading) {
return content;
}
final glyph =
line.kind == MarkupLineKind.bullet ? '•' : '${line.number ?? 1}.';
final glyphWidth = fontSize * 1.6;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: glyphWidth,
child: Text(
glyph,
textAlign: TextAlign.right,
style: style,
),
),
SizedBox(width: fontSize * 0.4),
Expanded(child: content),
],
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart';

class MarkupTextEditingController extends TextEditingController {
MarkupTextEditingController({super.text});

@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final baseStyle = style ?? DefaultTextStyle.of(context).style;
final mutedStyle = baseStyle.copyWith(
color: Settings.tacticalVioletTheme.mutedForeground,
);
final children = <InlineSpan>[];
final lines = parseMarkup(text);
for (var index = 0; index < lines.length; index++) {
final line = lines[index];
if (line.prefix.isNotEmpty) {
children.add(TextSpan(text: line.prefix, style: mutedStyle));
}
final headingStyle = line.kind == MarkupLineKind.heading
? baseStyle.copyWith(
fontWeight: FontWeight.w600,
fontSize: (baseStyle.fontSize ?? 14) * markupHeadingScale,
)
: baseStyle;
for (final inline in line.inlines) {
final inlineStyle = headingStyle.copyWith(
color: inline.isMarker
? Settings.tacticalVioletTheme.mutedForeground
: headingStyle.color,
fontWeight: inline.bold ? FontWeight.w700 : headingStyle.fontWeight,
fontStyle: inline.italic ? FontStyle.italic : headingStyle.fontStyle,
);
children.add(TextSpan(text: inline.raw, style: inlineStyle));
}
if (index != lines.length - 1) children.add(const TextSpan(text: '\n'));
}
return TextSpan(style: baseStyle, children: children);
}
}
131 changes: 131 additions & 0 deletions lib/widgets/draggable_widgets/text/text_format_bar.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/widgets/editor_toolbar.dart';
import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:shadcn_ui/shadcn_ui.dart';

class ToggleBoldIntent extends Intent {
const ToggleBoldIntent();
}

class ToggleItalicIntent extends Intent {
const ToggleItalicIntent();
}

class TextFormatBar extends StatelessWidget {
const TextFormatBar({
super.key,
required this.controller,
required this.onApply,
required this.tapRegionGroupId,
});

static const double _buttonSize = 28;
static const double _padding = 3;

static const double _gap = 2;

// Five evenly spaced buttons inside the padding and the 1px border. The
// overlay positions the bar from these.
static const double width = 5 * _buttonSize + 4 * _gap + 2 * _padding + 2;
static const double height = _buttonSize + 2 * _padding + 2;

final TextEditingController controller;
final ValueChanged<TextEditingValue> onApply;
final Object tapRegionGroupId;

@override
Widget build(BuildContext context) {
return ExcludeFocus(
child: TapRegion(
groupId: tapRegionGroupId,
child: ListenableBuilder(
listenable: controller,
builder: (context, _) {
final value = controller.value;
const style =
EditorToolbarButtonStyle(size: _buttonSize, iconSize: 16);
return Container(
width: width,
height: height,
padding: const EdgeInsets.all(_padding),
decoration: BoxDecoration(
color: Settings.tacticalVioletTheme.card,
// Tighter than the docked toolbar: the bar sits against the
// text card's near-square corners.
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Settings.tacticalVioletTheme.border),
boxShadow: const [Settings.floatingMenuShadow],
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: _gap,
children: [
EditorToolbarButton(
style: style,
showTooltip: false,
tooltip: 'Bold (Ctrl+B)',
active: MarkupEditing.isInlineActive(value, '**'),
onPressed: () => onApply(
MarkupEditing.toggleInline(value, '**'),
),
icon: const Icon(LucideIcons.bold200),
),
EditorToolbarButton(
style: style,
showTooltip: false,
tooltip: 'Italic (Ctrl+I)',
active: MarkupEditing.isInlineActive(value, '*'),
onPressed: () => onApply(
MarkupEditing.toggleInline(value, '*'),
),
icon: const Icon(LucideIcons.italic200),
),
EditorToolbarButton(
style: style,
showTooltip: false,
tooltip: 'Bullet list',
active: MarkupEditing.lineKindAt(value) ==
MarkupLineKind.bullet,
onPressed: () => onApply(
MarkupEditing.toggleLineKind(
value, MarkupLineKind.bullet),
),
icon: const Icon(LucideIcons.list200),
),
EditorToolbarButton(
style: style,
showTooltip: false,
tooltip: 'Numbered list',
active: MarkupEditing.lineKindAt(value) ==
MarkupLineKind.numbered,
onPressed: () => onApply(
MarkupEditing.toggleLineKind(
value,
MarkupLineKind.numbered,
),
),
icon: const Icon(LucideIcons.listOrdered200),
),
EditorToolbarButton(
style: style,
showTooltip: false,
tooltip: 'Heading',
active: MarkupEditing.lineKindAt(value) ==
MarkupLineKind.heading,
onPressed: () => onApply(
MarkupEditing.toggleLineKind(
value, MarkupLineKind.heading),
),
icon: const Icon(LucideIcons.heading200),
),
],
),
);
},
),
),
);
}
}
Loading
Loading