mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-07-06 01:00:18 +08:00
feat: cross row select (#2437)
* feat(wip): cross row select * fix patch Signed-off-by: dom <githubaccount56556@proton.me> * update Signed-off-by: dom <githubaccount56556@proton.me> --------- Co-authored-by: dom <githubaccount56556@proton.me>
This commit is contained in:
committed by
GitHub
parent
204715266f
commit
b540647222
@@ -74,21 +74,27 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
|
|||||||
if (isPortrait) {
|
if (isPortrait) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: padding),
|
padding: EdgeInsets.symmetric(horizontal: padding),
|
||||||
child: CustomScrollView(
|
child: SelectionArea(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
child: CustomScrollView(
|
||||||
slivers: [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
_buildContent(
|
slivers: [
|
||||||
maxWidth - this.padding.horizontal - 2 * padding - 24,
|
_buildContent(
|
||||||
),
|
maxWidth - this.padding.horizontal - 2 * padding - 24,
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Divider(
|
|
||||||
thickness: 8,
|
|
||||||
color: theme.dividerColor.withValues(alpha: 0.05),
|
|
||||||
),
|
),
|
||||||
),
|
SelectionContainer.disabled(
|
||||||
buildReplyHeader(),
|
child: SliverToBoxAdapter(
|
||||||
Obx(() => replyList(controller.loadingState.value)),
|
child: Divider(
|
||||||
],
|
thickness: 8,
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.05),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SelectionContainer.disabled(child: buildReplyHeader()),
|
||||||
|
SelectionContainer.disabled(
|
||||||
|
child: Obx(() => replyList(controller.loadingState.value)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,21 +107,25 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: flex,
|
flex: flex,
|
||||||
child: CustomScrollView(
|
child: SelectionArea(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
child: CustomScrollView(
|
||||||
slivers: [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
SliverPadding(
|
slivers: [
|
||||||
padding: EdgeInsets.only(
|
SliverPadding(
|
||||||
left: padding,
|
padding: EdgeInsets.only(
|
||||||
bottom: this.padding.bottom + 100,
|
left: padding,
|
||||||
|
bottom: this.padding.bottom + 100,
|
||||||
|
),
|
||||||
|
sliver: _buildContent(
|
||||||
|
(maxWidth - this.padding.horizontal) *
|
||||||
|
flex /
|
||||||
|
(flex + flex1) -
|
||||||
|
padding -
|
||||||
|
32,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
sliver: _buildContent(
|
],
|
||||||
(maxWidth - this.padding.horizontal) * flex / (flex + flex1) -
|
),
|
||||||
padding -
|
|
||||||
32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
VerticalDivider(
|
VerticalDivider(
|
||||||
@@ -151,7 +161,7 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
|
|||||||
sliver: Obx(
|
sliver: Obx(
|
||||||
() {
|
() {
|
||||||
if (controller.isLoaded.value) {
|
if (controller.isLoaded.value) {
|
||||||
late Widget content;
|
final Widget content;
|
||||||
if (controller.opus != null) {
|
if (controller.opus != null) {
|
||||||
// if (kDebugMode) debugPrint('json page');
|
// if (kDebugMode) debugPrint('json page');
|
||||||
content = OpusContent(
|
content = OpusContent(
|
||||||
@@ -159,9 +169,9 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
|
|||||||
images: controller.images,
|
images: controller.images,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
);
|
);
|
||||||
} else if (controller.opusData?.modules.moduleBlocked != null) {
|
} else if (controller.opusData?.modules.moduleBlocked
|
||||||
|
case final moduleBlocked?) {
|
||||||
// if (kDebugMode) debugPrint('moduleBlocked');
|
// if (kDebugMode) debugPrint('moduleBlocked');
|
||||||
final moduleBlocked = controller.opusData!.modules.moduleBlocked!;
|
|
||||||
content = SliverToBoxAdapter(
|
content = SliverToBoxAdapter(
|
||||||
child: moduleBlockedItem(context, theme, moduleBlocked),
|
child: moduleBlockedItem(context, theme, moduleBlocked),
|
||||||
);
|
);
|
||||||
@@ -201,128 +211,119 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
|
|||||||
content = const SliverToBoxAdapter(child: Text('NULL'));
|
content = const SliverToBoxAdapter(child: Text('NULL'));
|
||||||
}
|
}
|
||||||
|
|
||||||
int? pubTime =
|
final pubTime =
|
||||||
controller.opusData?.modules.moduleAuthor?.pubTs ??
|
controller.opusData?.modules.moduleAuthor?.pubTs ??
|
||||||
controller.articleData?.publishTime;
|
controller.articleData?.publishTime;
|
||||||
return SliverMainAxisGroup(
|
return SliverMainAxisGroup(
|
||||||
slivers: [
|
slivers: [
|
||||||
if (controller.type != 'read' &&
|
if (controller.type != 'read')
|
||||||
controller
|
if (controller.opusData?.modules.moduleTop?.display?.album?.pics
|
||||||
.opusData
|
case final pics? when pics.isNotEmpty)
|
||||||
?.modules
|
SliverToBoxAdapter(
|
||||||
.moduleTop
|
child: Builder(
|
||||||
?.display
|
builder: (context) {
|
||||||
?.album
|
final length = pics.length;
|
||||||
?.pics
|
final first = pics.first;
|
||||||
?.isNotEmpty ==
|
double height;
|
||||||
true)
|
if (first.height != null && first.width != null) {
|
||||||
SliverToBoxAdapter(
|
final ratio = first.height! / first.width!;
|
||||||
child: Builder(
|
height = min(maxWidth * ratio, maxHeight * 0.55);
|
||||||
builder: (context) {
|
} else {
|
||||||
final pics = controller
|
height = maxHeight * 0.55;
|
||||||
.opusData!
|
}
|
||||||
.modules
|
return Stack(
|
||||||
.moduleTop!
|
clipBehavior: Clip.none,
|
||||||
.display!
|
children: [
|
||||||
.album!
|
Container(
|
||||||
.pics!;
|
height: height,
|
||||||
final length = pics.length;
|
width: maxWidth,
|
||||||
final first = pics.first;
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
double height;
|
child: PageView.builder(
|
||||||
if (first.height != null && first.width != null) {
|
physics: clampingScrollPhysics,
|
||||||
final ratio = first.height! / first.width!;
|
onPageChanged: (value) =>
|
||||||
height = min(maxWidth * ratio, maxHeight * 0.55);
|
controller.topIndex.value = value,
|
||||||
} else {
|
itemCount: length,
|
||||||
height = maxHeight * 0.55;
|
itemBuilder: (context, index) {
|
||||||
}
|
final pic = pics[index];
|
||||||
return Stack(
|
int? memCacheWidth, memCacheHeight;
|
||||||
clipBehavior: Clip.none,
|
if (pic.isLongPic ?? false) {
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: height,
|
|
||||||
width: maxWidth,
|
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: PageView.builder(
|
|
||||||
physics: clampingScrollPhysics,
|
|
||||||
onPageChanged: (value) =>
|
|
||||||
controller.topIndex.value = value,
|
|
||||||
itemCount: length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final pic = pics[index];
|
|
||||||
int? memCacheWidth, memCacheHeight;
|
|
||||||
if (pic.isLongPic ?? false) {
|
|
||||||
memCacheWidth = maxWidth.cacheSize(context);
|
|
||||||
} else if (pic.width != null &&
|
|
||||||
pic.height != null) {
|
|
||||||
if (pic.width! > pic.height!) {
|
|
||||||
memCacheWidth = maxWidth.cacheSize(context);
|
memCacheWidth = maxWidth.cacheSize(context);
|
||||||
} else {
|
} else if (pic.width != null &&
|
||||||
memCacheHeight = height.cacheSize(context);
|
pic.height != null) {
|
||||||
|
if (pic.width! > pic.height!) {
|
||||||
|
memCacheWidth = maxWidth.cacheSize(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
memCacheHeight = height.cacheSize(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return GestureDetector(
|
||||||
return GestureDetector(
|
behavior: HitTestBehavior.opaque,
|
||||||
behavior: HitTestBehavior.opaque,
|
onTap: () => PageUtils.imageView(
|
||||||
onTap: () => PageUtils.imageView(
|
quality: 60,
|
||||||
quality: 60,
|
imgList: pics
|
||||||
imgList: pics
|
.map((e) => SourceModel(url: e.url!))
|
||||||
.map((e) => SourceModel(url: e.url!))
|
.toList(),
|
||||||
.toList(),
|
initialPage: index,
|
||||||
initialPage: index,
|
|
||||||
),
|
|
||||||
child: Hero(
|
|
||||||
tag: pic.url!,
|
|
||||||
child: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
CachedNetworkImage(
|
|
||||||
height: height,
|
|
||||||
width: maxWidth,
|
|
||||||
memCacheWidth: memCacheWidth,
|
|
||||||
memCacheHeight: memCacheHeight,
|
|
||||||
fit: pic.isLongPic == true
|
|
||||||
? BoxFit.cover
|
|
||||||
: null,
|
|
||||||
imageUrl: ImageUtils.thumbnailUrl(
|
|
||||||
pic.url,
|
|
||||||
60,
|
|
||||||
),
|
|
||||||
fadeInDuration: const Duration(
|
|
||||||
milliseconds: 120,
|
|
||||||
),
|
|
||||||
fadeOutDuration: const Duration(
|
|
||||||
milliseconds: 120,
|
|
||||||
),
|
|
||||||
placeholder: (_, _) =>
|
|
||||||
const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
if (pic.isLongPic == true)
|
|
||||||
const PBadge(
|
|
||||||
right: 12,
|
|
||||||
bottom: 12,
|
|
||||||
text: '长图',
|
|
||||||
type: .primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
child: Hero(
|
||||||
);
|
tag: pic.url!,
|
||||||
},
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CachedNetworkImage(
|
||||||
|
height: height,
|
||||||
|
width: maxWidth,
|
||||||
|
memCacheWidth: memCacheWidth,
|
||||||
|
memCacheHeight: memCacheHeight,
|
||||||
|
fit: pic.isLongPic == true
|
||||||
|
? BoxFit.cover
|
||||||
|
: null,
|
||||||
|
imageUrl: ImageUtils.thumbnailUrl(
|
||||||
|
pic.url,
|
||||||
|
60,
|
||||||
|
),
|
||||||
|
fadeInDuration: const Duration(
|
||||||
|
milliseconds: 120,
|
||||||
|
),
|
||||||
|
fadeOutDuration: const Duration(
|
||||||
|
milliseconds: 120,
|
||||||
|
),
|
||||||
|
placeholder: (_, _) =>
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
if (pic.isLongPic == true)
|
||||||
|
const PBadge(
|
||||||
|
right: 12,
|
||||||
|
bottom: 12,
|
||||||
|
text: '长图',
|
||||||
|
type: .primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Obx(
|
||||||
Obx(
|
() => PBadge(
|
||||||
() => PBadge(
|
top: 12,
|
||||||
top: 12,
|
right: 12,
|
||||||
right: 12,
|
type: PBadgeType.gray,
|
||||||
type: PBadgeType.gray,
|
text:
|
||||||
text: '${controller.topIndex.value + 1}/$length',
|
'${controller.topIndex.value + 1}/$length',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (controller.summary.title != null)
|
if (controller.summary.title != null)
|
||||||
SliverToBoxWithVisibilityAdapter(
|
SliverToBoxWithVisibilityAdapter(
|
||||||
onVisibilityChanged: (bool visible) =>
|
onVisibilityChanged: (bool visible) =>
|
||||||
@@ -342,39 +343,41 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
|
|||||||
onTap: () => Get.toNamed(
|
onTap: () => Get.toNamed(
|
||||||
'/member?mid=${controller.summary.author?.mid}',
|
'/member?mid=${controller.summary.author?.mid}',
|
||||||
),
|
),
|
||||||
child: Row(
|
child: SelectionContainer.disabled(
|
||||||
children: [
|
child: Row(
|
||||||
NetworkImgLayer(
|
children: [
|
||||||
width: 40,
|
NetworkImgLayer(
|
||||||
height: 40,
|
width: 40,
|
||||||
type: ImageType.avatar,
|
height: 40,
|
||||||
src: controller.summary.author?.face,
|
type: ImageType.avatar,
|
||||||
),
|
src: controller.summary.author?.face,
|
||||||
const SizedBox(width: 10),
|
),
|
||||||
Flexible(
|
const SizedBox(width: 10),
|
||||||
child: Column(
|
Flexible(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(
|
children: [
|
||||||
controller.summary.author?.name ?? '',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize:
|
|
||||||
theme.textTheme.titleSmall!.fontSize,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (pubTime != null)
|
|
||||||
Text(
|
Text(
|
||||||
DateFormatUtils.format(pubTime),
|
controller.summary.author?.name ?? '',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: theme.colorScheme.outline,
|
|
||||||
fontSize:
|
fontSize:
|
||||||
theme.textTheme.labelSmall!.fontSize,
|
theme.textTheme.titleSmall!.fontSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (pubTime != null)
|
||||||
|
Text(
|
||||||
|
DateFormatUtils.format(pubTime),
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.outline,
|
||||||
|
fontSize:
|
||||||
|
theme.textTheme.labelSmall!.fontSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -382,9 +385,11 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
|
|||||||
if (controller.type != 'read' &&
|
if (controller.type != 'read' &&
|
||||||
controller.opusData?.modules.moduleCollection != null)
|
controller.opusData?.modules.moduleCollection != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: opusCollection(
|
child: SelectionContainer.disabled(
|
||||||
theme,
|
child: opusCollection(
|
||||||
controller.opusData!.modules.moduleCollection!,
|
theme,
|
||||||
|
controller.opusData!.modules.moduleCollection!,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class ArticleOpus extends StatelessWidget {
|
|||||||
final item = _ops[index];
|
final item = _ops[index];
|
||||||
switch (item.insert) {
|
switch (item.insert) {
|
||||||
case String e:
|
case String e:
|
||||||
return SelectableText(e);
|
return Text(e);
|
||||||
case Insert(:final card):
|
case Insert(:final card):
|
||||||
if (card != null) {
|
if (card != null) {
|
||||||
if (card.url?.isNotEmpty == true) {
|
if (card.url?.isNotEmpty == true) {
|
||||||
|
|||||||
@@ -125,17 +125,15 @@ Widget htmlRender({
|
|||||||
margin: Margins.zero,
|
margin: Margins.zero,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
return SelectionArea(
|
return element != null
|
||||||
child: element != null
|
? Html.fromElement(
|
||||||
? Html.fromElement(
|
documentElement: element,
|
||||||
documentElement: element,
|
extensions: extensions,
|
||||||
extensions: extensions,
|
style: style,
|
||||||
style: style,
|
)
|
||||||
)
|
: Html(
|
||||||
: Html(
|
data: html,
|
||||||
data: html,
|
extensions: extensions,
|
||||||
extensions: extensions,
|
style: style,
|
||||||
style: style,
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ class OpusContent extends StatelessWidget {
|
|||||||
switch (element.paraType) {
|
switch (element.paraType) {
|
||||||
case 1 || 4:
|
case 1 || 4:
|
||||||
final isQuote = element.paraType == 4;
|
final isQuote = element.paraType == 4;
|
||||||
Widget widget = SelectableText.rich(
|
Widget widget = Text.rich(
|
||||||
textAlign: element.align == 1 ? TextAlign.center : null,
|
textAlign: element.align == 1 ? TextAlign.center : null,
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: element.text?.nodes
|
children: element.text?.nodes
|
||||||
@@ -194,12 +194,7 @@ class OpusContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
if (isQuote) {
|
if (isQuote) {
|
||||||
widget = Container(
|
widget = Container(
|
||||||
padding: const EdgeInsets.only(
|
padding: const .only(left: 8, top: 4, right: 4, bottom: 4),
|
||||||
left: 8,
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
bottom: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
left: BorderSide(
|
left: BorderSide(
|
||||||
@@ -215,8 +210,9 @@ class OpusContent extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return widget;
|
return widget;
|
||||||
case 2 when (element.pic != null):
|
case 2 when (element.pic != null):
|
||||||
if (element.pic!.pics!.length == 1) {
|
final pics = element.pic!.pics!;
|
||||||
final pic = element.pic!.pics!.first;
|
if (pics.length == 1) {
|
||||||
|
final pic = pics.first;
|
||||||
double? width = pic.width == null
|
double? width = pic.width == null
|
||||||
? null
|
? null
|
||||||
: math.min(maxWidth, pic.width!);
|
: math.min(maxWidth, pic.width!);
|
||||||
@@ -250,7 +246,7 @@ class OpusContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return ImageGridView(
|
return ImageGridView(
|
||||||
picArr: element.pic!.pics!
|
picArr: pics
|
||||||
.map(
|
.map(
|
||||||
(e) => ImageModel(
|
(e) => ImageModel(
|
||||||
width: e.width,
|
width: e.width,
|
||||||
@@ -262,19 +258,20 @@ class OpusContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 3 when (element.line?.pic != null):
|
case 3 when (element.line?.pic != null):
|
||||||
final height = element.line!.pic!.height?.toDouble();
|
final pic = element.line!.pic!;
|
||||||
|
final height = pic.height?.toDouble();
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
fit: .contain,
|
fit: .contain,
|
||||||
height: height,
|
height: height,
|
||||||
width: maxWidth,
|
width: maxWidth,
|
||||||
memCacheWidth: maxWidth.cacheSize(context),
|
memCacheWidth: maxWidth.cacheSize(context),
|
||||||
imageUrl: ImageUtils.thumbnailUrl(element.line!.pic!.url!),
|
imageUrl: ImageUtils.thumbnailUrl(pic.url!),
|
||||||
placeholder: (_, _) => const SizedBox.shrink(),
|
placeholder: (_, _) => const SizedBox.shrink(),
|
||||||
);
|
);
|
||||||
case 5 when (element.list != null):
|
case 5 when (element.list?.items?.isNotEmpty == true):
|
||||||
return SelectableText.rich(
|
return Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: element.list!.items?.mapIndexed((i, entry) {
|
children: element.list!.items!.mapIndexed((i, entry) {
|
||||||
return TextSpan(
|
return TextSpan(
|
||||||
children: [
|
children: [
|
||||||
const WidgetSpan(
|
const WidgetSpan(
|
||||||
@@ -589,39 +586,32 @@ class OpusContent extends StatelessWidget {
|
|||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
try {
|
try {
|
||||||
|
final card = element.linkCard!.card!;
|
||||||
if (type == 'LINK_CARD_TYPE_VOTE') {
|
if (type == 'LINK_CARD_TYPE_VOTE') {
|
||||||
showVoteDialog(
|
showVoteDialog(
|
||||||
context,
|
context,
|
||||||
element.linkCard!.card!.vote?.voteId ??
|
card.vote?.voteId ?? int.parse(card.oid!),
|
||||||
int.parse(element.linkCard!.card!.oid!),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type == 'LINK_CARD_TYPE_ITEM_NULL') {
|
if (type == 'LINK_CARD_TYPE_ITEM_NULL') {
|
||||||
switch (element.linkCard?.card?.itemNull?.text) {
|
switch (card.itemNull?.text) {
|
||||||
case '视频':
|
case '视频':
|
||||||
PiliScheme.videoPush(
|
PiliScheme.videoPush(
|
||||||
int.parse(element.linkCard!.card!.oid!),
|
int.parse(card.oid!),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
PageUtils.pushDynFromId(
|
PageUtils.pushDynFromId(id: card.oid!);
|
||||||
id: element.linkCard!.card!.oid!,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String? url = switch (type) {
|
final url = switch (type) {
|
||||||
'LINK_CARD_TYPE_UGC' =>
|
'LINK_CARD_TYPE_UGC' => card.ugc!.jumpUrl,
|
||||||
element.linkCard!.card!.ugc!.jumpUrl,
|
'LINK_CARD_TYPE_COMMON' => card.common!.jumpUrl,
|
||||||
'LINK_CARD_TYPE_COMMON' =>
|
'LINK_CARD_TYPE_LIVE' => card.live!.jumpUrl,
|
||||||
element.linkCard!.card!.common!.jumpUrl,
|
'LINK_CARD_TYPE_OPUS' => card.opus!.jumpUrl,
|
||||||
'LINK_CARD_TYPE_LIVE' =>
|
'LINK_CARD_TYPE_MUSIC' => card.music!.jumpUrl,
|
||||||
element.linkCard!.card!.live!.jumpUrl,
|
|
||||||
'LINK_CARD_TYPE_OPUS' =>
|
|
||||||
element.linkCard!.card!.opus!.jumpUrl,
|
|
||||||
'LINK_CARD_TYPE_MUSIC' =>
|
|
||||||
element.linkCard!.card!.music!.jumpUrl,
|
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
if (url != null && url.isNotEmpty) {
|
if (url != null && url.isNotEmpty) {
|
||||||
@@ -660,10 +650,10 @@ class OpusContent extends StatelessWidget {
|
|||||||
color: colorScheme.onInverseSurface,
|
color: colorScheme.onInverseSurface,
|
||||||
),
|
),
|
||||||
width: .infinity,
|
width: .infinity,
|
||||||
child: SelectableText.rich(renderer.span!),
|
child: Text.rich(renderer.span!),
|
||||||
);
|
);
|
||||||
case 8 when (element.heading?.nodes?.isNotEmpty == true):
|
case 8 when (element.heading?.nodes?.isNotEmpty == true):
|
||||||
return SelectableText.rich(
|
return Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: element.heading!.nodes!
|
children: element.heading!.nodes!
|
||||||
.map(
|
.map(
|
||||||
@@ -679,7 +669,7 @@ class OpusContent extends StatelessWidget {
|
|||||||
default:
|
default:
|
||||||
if (kDebugMode) debugPrint('unknown type ${element.paraType}');
|
if (kDebugMode) debugPrint('unknown type ${element.paraType}');
|
||||||
if (element.text?.nodes?.isNotEmpty == true) {
|
if (element.text?.nodes?.isNotEmpty == true) {
|
||||||
return SelectableText.rich(
|
return Text.rich(
|
||||||
textAlign: element.align == 1 ? TextAlign.center : null,
|
textAlign: element.align == 1 ? TextAlign.center : null,
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: element.text!.nodes!
|
children: element.text!.nodes!
|
||||||
@@ -694,7 +684,7 @@ class OpusContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SelectableText(
|
return Text(
|
||||||
'不支持的类型 (${element.paraType})',
|
'不支持的类型 (${element.paraType})',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: .bold,
|
fontWeight: .bold,
|
||||||
@@ -703,7 +693,7 @@ class OpusContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
return SelectableText(
|
return Text(
|
||||||
'错误的类型 $e${kDebugMode ? '\n$s' : ''}',
|
'错误的类型 $e${kDebugMode ? '\n$s' : ''}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: .bold,
|
fontWeight: .bold,
|
||||||
|
|||||||
249
lib/scripts/null_safety_for_selectable_region.patch
Normal file
249
lib/scripts/null_safety_for_selectable_region.patch
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart
|
||||||
|
index 59de8bae20b..5a6923f3a6a 100644
|
||||||
|
--- a/packages/flutter/lib/src/widgets/selectable_region.dart
|
||||||
|
+++ b/packages/flutter/lib/src/widgets/selectable_region.dart
|
||||||
|
@@ -29,8 +29,10 @@ import 'framework.dart';
|
||||||
|
import 'gesture_detector.dart';
|
||||||
|
import 'magnifier.dart';
|
||||||
|
import 'media_query.dart';
|
||||||
|
+import 'notification_listener.dart';
|
||||||
|
import 'overlay.dart';
|
||||||
|
import 'platform_selectable_region_context_menu.dart';
|
||||||
|
+import 'scroll_notification.dart';
|
||||||
|
import 'selection_container.dart';
|
||||||
|
import 'tap_region.dart';
|
||||||
|
import 'text_editing_intents.dart';
|
||||||
|
@@ -1775,12 +1777,12 @@ class SelectableRegionState extends State<SelectableRegion>
|
||||||
|
|
||||||
|
/// The line height at the start of the current selection.
|
||||||
|
double get startGlyphHeight {
|
||||||
|
- return _selectionDelegate.value.startSelectionPoint!.lineHeight;
|
||||||
|
+ return _selectionDelegate.value.startSelectionPoint?.lineHeight ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line height at the end of the current selection.
|
||||||
|
double get endGlyphHeight {
|
||||||
|
- return _selectionDelegate.value.endSelectionPoint!.lineHeight;
|
||||||
|
+ return _selectionDelegate.value.endSelectionPoint?.lineHeight ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the local coordinates of the endpoints of the current selection.
|
||||||
|
@@ -1946,7 +1948,7 @@ class SelectableRegionState extends State<SelectableRegion>
|
||||||
|
if (_webContextMenuEnabled) {
|
||||||
|
result = PlatformSelectableRegionContextMenu(child: result);
|
||||||
|
}
|
||||||
|
- return TapRegion(
|
||||||
|
+ final tapRegion = TapRegion(
|
||||||
|
groupId: SelectableRegion,
|
||||||
|
onTapOutside: (PointerDownEvent event) {
|
||||||
|
// To match the native web behavior, this selectable region is
|
||||||
|
@@ -1976,6 +1978,22 @@ class SelectableRegionState extends State<SelectableRegion>
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
+ return NotificationListener<ScrollEndNotification>(
|
||||||
|
+ onNotification: (notification) {
|
||||||
|
+ _selectionDelegate.layoutDidChange();
|
||||||
|
+ _updateSelectedContentIfNeeded();
|
||||||
|
+ final SelectedContent? lastSelection = _lastSelectedContent;
|
||||||
|
+ if (lastSelection != null && lastSelection.plainText.isNotEmpty) {
|
||||||
|
+ _showHandles();
|
||||||
|
+ final SelectionOverlay? selectionOverlay = _selectionOverlay;
|
||||||
|
+ if (selectionOverlay == null || !selectionOverlay.toolbarIsVisible) {
|
||||||
|
+ _showToolbar();
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ return false;
|
||||||
|
+ },
|
||||||
|
+ child: tapRegion,
|
||||||
|
+ );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart
|
||||||
|
index f7bd4071c8a..a82a9448120 100644
|
||||||
|
--- a/packages/flutter/test/widgets/selectable_region_test.dart
|
||||||
|
+++ b/packages/flutter/test/widgets/selectable_region_test.dart
|
||||||
|
@@ -841,6 +841,125 @@ void main() {
|
||||||
|
expect(renderSelectionSpy.events.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
+ testWidgets(
|
||||||
|
+ 'SelectionArea + ListView: select across multiple lines and scroll does not crash',
|
||||||
|
+ (WidgetTester tester) async {
|
||||||
|
+ // Regression test: when selection spans across ListView items and user scrolls,
|
||||||
|
+ // startSelectionPoint/endSelectionPoint may be null (off-screen). Verifies that
|
||||||
|
+ // startGlyphHeight/endGlyphHeight return 0 and do not crash.
|
||||||
|
+ await tester.pumpWidget(
|
||||||
|
+ MaterialApp(
|
||||||
|
+ home: SelectableRegion(
|
||||||
|
+ selectionControls: materialTextSelectionControls,
|
||||||
|
+ child: SizedBox(
|
||||||
|
+ height: 400,
|
||||||
|
+ child: ListView(
|
||||||
|
+ children: List<Widget>.generate(
|
||||||
|
+ 15,
|
||||||
|
+ (int i) => Text(
|
||||||
|
+ 'Line $i: Selectable text content for testing',
|
||||||
|
+ style: const TextStyle(fontSize: 20),
|
||||||
|
+ ),
|
||||||
|
+ ),
|
||||||
|
+ ),
|
||||||
|
+ ),
|
||||||
|
+ ),
|
||||||
|
+ ),
|
||||||
|
+ );
|
||||||
|
+ await tester.pumpAndSettle();
|
||||||
|
+
|
||||||
|
+ final RenderParagraph firstParagraph = tester.renderObject<RenderParagraph>(
|
||||||
|
+ find.descendant(
|
||||||
|
+ of: find.text('Line 0: Selectable text content for testing'),
|
||||||
|
+ matching: find.byType(RichText),
|
||||||
|
+ ),
|
||||||
|
+ );
|
||||||
|
+ final RenderParagraph thirdParagraph = tester.renderObject<RenderParagraph>(
|
||||||
|
+ find.descendant(
|
||||||
|
+ of: find.text('Line 2: Selectable text content for testing'),
|
||||||
|
+ matching: find.byType(RichText),
|
||||||
|
+ ),
|
||||||
|
+ );
|
||||||
|
+
|
||||||
|
+ // Select from first line to third line (cross-line selection).
|
||||||
|
+ final TestGesture selectGesture = await tester.startGesture(
|
||||||
|
+ textOffsetToPosition(firstParagraph, 2),
|
||||||
|
+ kind: PointerDeviceKind.mouse,
|
||||||
|
+ );
|
||||||
|
+ addTearDown(selectGesture.removePointer);
|
||||||
|
+ await tester.pump();
|
||||||
|
+ await selectGesture.moveTo(textOffsetToPosition(thirdParagraph, 15));
|
||||||
|
+ await tester.pump();
|
||||||
|
+ await selectGesture.up();
|
||||||
|
+ await tester.pumpAndSettle();
|
||||||
|
+
|
||||||
|
+ // Verify selection exists.
|
||||||
|
+ expect(firstParagraph.selections.isNotEmpty, isTrue);
|
||||||
|
+ expect(thirdParagraph.selections.isNotEmpty, isTrue);
|
||||||
|
+
|
||||||
|
+ // Simulate scroll - this may cause selection points to become null when off-screen.
|
||||||
|
+ await tester.drag(find.byType(ListView), const Offset(0, -150));
|
||||||
|
+ await tester.pumpAndSettle();
|
||||||
|
+
|
||||||
|
+ // Should not throw - startGlyphHeight/endGlyphHeight return 0 when points are null.
|
||||||
|
+ expect(tester.takeException(), isNull);
|
||||||
|
+ },
|
||||||
|
+ skip: kIsWeb, // [intended] Web uses its native context menu.
|
||||||
|
+ );
|
||||||
|
+
|
||||||
|
+ testWidgets(
|
||||||
|
+ 'startGlyphHeight and endGlyphHeight return 0 when startSelectionPoint/endSelectionPoint are null',
|
||||||
|
+ (WidgetTester tester) async {
|
||||||
|
+ // Directly tests the fix: when SelectionGeometry has null start/end points
|
||||||
|
+ // (e.g. selection off-screen in scrollable), startGlyphHeight and
|
||||||
|
+ // endGlyphHeight must not crash and must return 0.
|
||||||
|
+ final delegateKey = GlobalKey<NullSelectionPointDelegateWidgetState>();
|
||||||
|
+
|
||||||
|
+ await tester.pumpWidget(
|
||||||
|
+ MaterialApp(
|
||||||
|
+ home: SelectableRegion(
|
||||||
|
+ selectionControls: materialTextSelectionControls,
|
||||||
|
+ child: NullSelectionPointDelegateWidget(
|
||||||
|
+ key: delegateKey,
|
||||||
|
+ child: const Column(
|
||||||
|
+ children: <Widget>[Text('First'), Text('Second'), Text('Third')],
|
||||||
|
+ ),
|
||||||
|
+ ),
|
||||||
|
+ ),
|
||||||
|
+ ),
|
||||||
|
+ );
|
||||||
|
+ await tester.pumpAndSettle();
|
||||||
|
+
|
||||||
|
+ final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
|
||||||
|
+ find.descendant(of: find.text('Second'), matching: find.byType(RichText)),
|
||||||
|
+ );
|
||||||
|
+ final TestGesture gesture = await tester.startGesture(
|
||||||
|
+ textOffsetToPosition(paragraph, 2),
|
||||||
|
+ kind: PointerDeviceKind.mouse,
|
||||||
|
+ );
|
||||||
|
+ addTearDown(gesture.removePointer);
|
||||||
|
+ await gesture.moveTo(textOffsetToPosition(paragraph, 5));
|
||||||
|
+ await gesture.up();
|
||||||
|
+ await tester.pumpAndSettle();
|
||||||
|
+
|
||||||
|
+ // Switch delegate to return null selection points (simulates off-screen scenario).
|
||||||
|
+ final NullSelectionPointDelegate delegate = delegateKey.currentState!.delegate;
|
||||||
|
+ delegate.useNullSelectionPoints = true;
|
||||||
|
+ delegate.notifyListeners();
|
||||||
|
+ await tester.pump();
|
||||||
|
+
|
||||||
|
+ final SelectableRegionState state = tester.state<SelectableRegionState>(
|
||||||
|
+ find.byType(SelectableRegion),
|
||||||
|
+ );
|
||||||
|
+ // Access the getters - should not throw, must return 0 when points are null.
|
||||||
|
+ expect(() => state.startGlyphHeight, returnsNormally);
|
||||||
|
+ expect(() => state.endGlyphHeight, returnsNormally);
|
||||||
|
+ expect(state.startGlyphHeight, 0);
|
||||||
|
+ expect(state.endGlyphHeight, 0);
|
||||||
|
+ },
|
||||||
|
+ skip: kIsWeb, // [intended] Web uses its native context menu.
|
||||||
|
+ );
|
||||||
|
+
|
||||||
|
testWidgets('mouse long press does not send select-word event', (WidgetTester tester) async {
|
||||||
|
final spy = UniqueKey();
|
||||||
|
|
||||||
|
@@ -6690,6 +6809,56 @@ void main() {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
+/// A [SelectionContainerDelegate] that can return [SelectionGeometry] with null
|
||||||
|
+/// [startSelectionPoint] and [endSelectionPoint] to test off-screen selection handling.
|
||||||
|
+class NullSelectionPointDelegate extends StaticSelectionContainerDelegate {
|
||||||
|
+ bool useNullSelectionPoints = false;
|
||||||
|
+
|
||||||
|
+ @override
|
||||||
|
+ SelectionGeometry get value {
|
||||||
|
+ final SelectionGeometry geometry = super.value;
|
||||||
|
+ if (useNullSelectionPoints && geometry.hasSelection) {
|
||||||
|
+ return SelectionGeometry(
|
||||||
|
+ status: geometry.status,
|
||||||
|
+ hasContent: geometry.hasContent,
|
||||||
|
+ selectionRects: geometry.selectionRects,
|
||||||
|
+ );
|
||||||
|
+ }
|
||||||
|
+ return geometry;
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// Widget that holds [NullSelectionPointDelegate] for testing.
|
||||||
|
+class NullSelectionPointDelegateWidget extends StatefulWidget {
|
||||||
|
+ const NullSelectionPointDelegateWidget({super.key, required this.child});
|
||||||
|
+
|
||||||
|
+ final Widget child;
|
||||||
|
+
|
||||||
|
+ @override
|
||||||
|
+ State<NullSelectionPointDelegateWidget> createState() => NullSelectionPointDelegateWidgetState();
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+class NullSelectionPointDelegateWidgetState extends State<NullSelectionPointDelegateWidget> {
|
||||||
|
+ late final NullSelectionPointDelegate delegate;
|
||||||
|
+
|
||||||
|
+ @override
|
||||||
|
+ void initState() {
|
||||||
|
+ super.initState();
|
||||||
|
+ delegate = NullSelectionPointDelegate();
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ @override
|
||||||
|
+ void dispose() {
|
||||||
|
+ delegate.dispose();
|
||||||
|
+ super.dispose();
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ @override
|
||||||
|
+ Widget build(BuildContext context) {
|
||||||
|
+ return SelectionContainer(delegate: delegate, child: widget.child);
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
class ColumnSelectionContainerDelegate extends StaticSelectionContainerDelegate {
|
||||||
|
/// Copies the selected contents of all [Selectable]s, separating their
|
||||||
|
/// contents with a new line.
|
||||||
@@ -33,6 +33,10 @@ $PopupMenuPatch = "lib/scripts/popup_menu.patch"
|
|||||||
|
|
||||||
$FABPatch = "lib/scripts/fab.patch"
|
$FABPatch = "lib/scripts/fab.patch"
|
||||||
|
|
||||||
|
# TODO: remove
|
||||||
|
# https://github.com/flutter/flutter/pull/183261
|
||||||
|
$SelectableRegionPatch = "lib/scripts/null_safety_for_selectable_region.patch"
|
||||||
|
|
||||||
# TODO: remove
|
# TODO: remove
|
||||||
# https://github.com/flutter/flutter/issues/90223
|
# https://github.com/flutter/flutter/issues/90223
|
||||||
$ModalBarrierPatch = "lib/scripts/modal_barrier.patch"
|
$ModalBarrierPatch = "lib/scripts/modal_barrier.patch"
|
||||||
@@ -60,7 +64,7 @@ $picks = @()
|
|||||||
$reverts = @()
|
$reverts = @()
|
||||||
$patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch,
|
$patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch,
|
||||||
$ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch,
|
$ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch,
|
||||||
$PopupMenuPatch, $FABPatch)
|
$PopupMenuPatch, $FABPatch, $SelectableRegionPatch)
|
||||||
|
|
||||||
switch ($platform.ToLower()) {
|
switch ($platform.ToLower()) {
|
||||||
"android" {
|
"android" {
|
||||||
|
|||||||
Reference in New Issue
Block a user